From 8a2f72d366c570dbd71452fde660ab4e252f53c3 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Sun, 21 Jun 2026 00:16:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(user):=20=E6=98=B5=E7=A7=B0=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E6=94=BE=E5=AE=BD=E5=88=B0=2020=20=E5=AD=97(=E4=B8=8E?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF/=E5=8E=9F=E5=9E=8B=E4=B8=80?= =?UTF-8?q?=E8=87=B4)=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/63 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- alembic/versions/add_feed_scene.py | 34 ++ ...erge_add_feed_scene_and_launch_confirm_.py | 26 ++ app/admin/main.py | 2 + app/admin/repositories/queries.py | 215 +++++++++++++ app/admin/routers/feedback_qr.py | 92 ++++++ app/admin/routers/users.py | 42 +++ app/admin/routers/withdraw.py | 20 +- app/admin/schemas/feedback_qr.py | 22 ++ app/admin/schemas/user.py | 30 ++ app/admin/schemas/wallet.py | 11 + app/api/v1/ad.py | 1 + app/api/v1/feedback.py | 9 +- app/core/config.py | 10 +- app/core/daily_exchange_worker.py | 125 ++++++++ app/core/media.py | 32 +- app/main.py | 6 + app/models/ad_feed_reward.py | 3 + app/repositories/ad_feed_reward.py | 6 + app/repositories/feedback_qr.py | 106 +++++++ app/schemas/ad.py | 6 + app/schemas/feedback.py | 14 + deploy/daily-exchange.md | 12 +- scripts/seed_mock_price_reports.py | 208 ++++++++++++ scripts/seed_mock_withdraws.py | 296 ++++++++++++++++++ 24 files changed, 1310 insertions(+), 18 deletions(-) create mode 100644 alembic/versions/add_feed_scene.py create mode 100644 alembic/versions/ceb286289426_merge_add_feed_scene_and_launch_confirm_.py create mode 100644 app/admin/routers/feedback_qr.py create mode 100644 app/admin/schemas/feedback_qr.py create mode 100644 app/core/daily_exchange_worker.py create mode 100644 app/repositories/feedback_qr.py create mode 100644 scripts/seed_mock_price_reports.py create mode 100644 scripts/seed_mock_withdraws.py diff --git a/alembic/versions/add_feed_scene.py b/alembic/versions/add_feed_scene.py new file mode 100644 index 0000000..19f8bc2 --- /dev/null +++ b/alembic/versions/add_feed_scene.py @@ -0,0 +1,34 @@ +"""add feed_scene to ad_feed_reward_record + +信息流广告点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。 +比价与领券共用同一个信息流代码位(AdConfig.feedCodeId),slot_id / our_code_id 都分不出来, +只能由客户端在各调用点(PriceBotService / CouponForegroundService / WelfareScreen)显式打标。 +可空:历史记录与未升级客户端为 NULL = 未分类。 + +Revision ID: add_feed_scene +Revises: drop_force_onboarding +Create Date: 2026-06-20 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "add_feed_scene" +down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "ad_feed_reward_record", + sa.Column("feed_scene", sa.String(length=16), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("ad_feed_reward_record", "feed_scene") diff --git a/alembic/versions/ceb286289426_merge_add_feed_scene_and_launch_confirm_.py b/alembic/versions/ceb286289426_merge_add_feed_scene_and_launch_confirm_.py new file mode 100644 index 0000000..7946070 --- /dev/null +++ b/alembic/versions/ceb286289426_merge_add_feed_scene_and_launch_confirm_.py @@ -0,0 +1,26 @@ +"""merge add_feed_scene and launch_confirm_sample heads + +Revision ID: ceb286289426 +Revises: add_feed_scene, launch_confirm_sample_table +Create Date: 2026-06-20 23:52:57.853400 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ceb286289426' +down_revision: Union[str, Sequence[str], None] = ('add_feed_scene', 'launch_confirm_sample_table') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app/admin/main.py b/app/admin/main.py index eb46a7c..8dfdf77 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -25,6 +25,7 @@ from app.admin.routers.cps import router as cps_router from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.ops_stat_config import router as ops_stat_config_router from app.admin.routers.feedback import router as feedback_router +from app.admin.routers.feedback_qr import router as feedback_qr_router from app.admin.routers.onboarding import router as onboarding_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router from app.admin.routers.price_report import router as price_report_router @@ -90,6 +91,7 @@ admin_app.include_router(wallet_router) admin_app.include_router(withdraw_router) admin_app.include_router(price_report_router) admin_app.include_router(feedback_router) +admin_app.include_router(feedback_qr_router) admin_app.include_router(admins_router) admin_app.include_router(audit_router) admin_app.include_router(config_router) diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 7294ab7..4e7f2d2 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -11,6 +11,9 @@ from zoneinfo import ZoneInfo from sqlalchemy import Select, asc, desc, func, or_, select from sqlalchemy.orm import Session +from app.core import rewards +from app.models.ad_feed_reward import AdFeedRewardRecord +from app.models.ad_reward import AdRewardRecord from app.models.admin import AdminAuditLog from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback @@ -19,6 +22,16 @@ from app.models.price_report import PriceReport from app.models.user import User from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder +# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取") +_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct") + +# 信息流点位场景 → 金币记录「赚取途径」展示名;NULL/未知 = 历史未分类。 +_FEED_SCENE_LABEL = { + "comparison": "比价信息流", + "coupon": "领券信息流", + "welfare": "福利信息流", +} + def cursor_paginate( db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None @@ -331,6 +344,38 @@ def _as_utc(value: datetime) -> datetime: return value.astimezone(timezone.utc) +def withdraw_list_enrichment(db: Session, user_ids: list[int]) -> dict[int, dict]: + """批量富化提现单列表:按本页 user_id 取 手机号/昵称 + 各自累计成功提现金额(分)。 + + 两条聚合查询搞定(避免逐行 N+1)。累计口径与 get_user_overview 的 withdraw_success_cents + 完全一致:SUM(amount_cents) WHERE status='success'。返回 {user_id: {phone, nickname, + cumulative_success_cents}};某 user_id 查不到用户则不在 dict 里(路由侧回退到默认值)。 + """ + uniq = list(set(user_ids)) + if not uniq: + return {} + success_rows = db.execute( + select( + WithdrawOrder.user_id, + func.coalesce(func.sum(WithdrawOrder.amount_cents), 0), + ) + .where(WithdrawOrder.user_id.in_(uniq), WithdrawOrder.status == "success") + .group_by(WithdrawOrder.user_id) + ).all() + success_map = {uid: int(total) for uid, total in success_rows} + users = db.execute( + select(User.id, User.phone, User.nickname).where(User.id.in_(uniq)) + ).all() + return { + uid: { + "phone": phone, + "nickname": nickname, + "cumulative_success_cents": success_map.get(uid, 0), + } + for uid, phone, nickname in users + } + + def list_feedbacks( db: Session, *, @@ -546,6 +591,176 @@ def get_user_overview(db: Session, user_id: int) -> dict | None: } +def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list: + """把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。""" + conds = [] + if date_from is not None: + conds.append(col >= _as_utc_naive(date_from)) + if date_to is not None: + conds.append(col <= _as_utc_naive(date_to)) + return conds + + +def _coins_to_cents(coins: int) -> int: + """累计金币折算成可提现现金(分)。COIN_PER_YUAN=10000 → 100 金币 = 1 分。""" + return round(coins / (rewards.COIN_PER_YUAN / 100)) + + +def user_reward_stats( + db: Session, + user_id: int, + *, + date_from: datetime | None = None, + date_to: datetime | None = None, +) -> dict: + """提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。 + + 口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加); + 平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。 + 传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。 + """ + wd_success = db.execute( + select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where( + WithdrawOrder.user_id == user_id, + WithdrawOrder.status == "success", + *_window_conds(WithdrawOrder.created_at, date_from, date_to), + ) + ).scalar_one() + wd_total = db.execute( + select(func.count(WithdrawOrder.id)).where( + WithdrawOrder.user_id == user_id, + *_window_conds(WithdrawOrder.created_at, date_from, date_to), + ) + ).scalar_one() + + acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口 + cash_balance = acc.cash_balance_cents if acc else 0 + + rv = list(db.execute( + select(AdRewardRecord).where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.reward_scene == "reward_video", + AdRewardRecord.status == "granted", + *_window_conds(AdRewardRecord.created_at, date_from, date_to), + ) + ).scalars()) + rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw] + rv_coins = sum(r.coin for r in rv) + + feed = list(db.execute( + select(AdFeedRewardRecord).where( + AdFeedRewardRecord.user_id == user_id, + AdFeedRewardRecord.status == "granted", + *_window_conds(AdFeedRewardRecord.created_at, date_from, date_to), + ) + ).scalars()) + feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw] + feed_coins = sum(f.coin for f in feed) + + trad_coins = db.execute( + select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where( + CoinTransaction.user_id == user_id, + CoinTransaction.amount > 0, + CoinTransaction.biz_type.not_in(_NON_TASK_BIZ_TYPES), + *_window_conds(CoinTransaction.created_at, date_from, date_to), + ) + ).scalar_one() + + return { + "withdraw_success_cents": int(wd_success), + "cash_balance_cents": int(cash_balance), + "withdraw_total": int(wd_total), + "traditional_task_cash_cents": _coins_to_cents(int(trad_coins)), + "reward_video_count": len(rv), + "reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0, + "reward_video_cash_cents": _coins_to_cents(rv_coins), + "feed_count": int(sum(f.unit_count for f in feed)), + "feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0, + "feed_cash_cents": _coins_to_cents(feed_coins), + } + + +def user_coin_records( + db: Session, + user_id: int, + *, + date_from: datetime | None = None, + date_to: datetime | None = None, + limit: int = 20, + cursor: int | None = None, +) -> tuple[list[dict], int | None]: + """金币发放记录(提现详情底部表),按时间倒序、offset 游标分页。 + + 合并三类来源(Phase1 信息流不分比价/领券): + - 激励视频 / 签到膨胀 = ad_reward_record(granted) + - 信息流广告 = ad_feed_reward_record(granted) + - 签到 = coin_transaction(biz_type='signin') + 每来源各取 offset+limit+1 条(倒序)再归并切片,避免 union 全量拉取。 + """ + offset = max(cursor or 0, 0) + fetch = offset + limit + 1 + rows: list[dict] = [] + + for rec in db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.status == "granted", + *_window_conds(AdRewardRecord.created_at, date_from, date_to), + ) + .order_by(AdRewardRecord.created_at.desc()) + .limit(fetch) + ).scalars(): + is_video = rec.reward_scene == "reward_video" + rows.append({ + "source": rec.reward_scene, + "source_label": "激励视频" if is_video else "签到膨胀", + "created_at": rec.created_at, + "ecpm": rec.ecpm_raw, + "coin": rec.coin, + }) + + for rec in db.execute( + select(AdFeedRewardRecord) + .where( + AdFeedRewardRecord.user_id == user_id, + AdFeedRewardRecord.status == "granted", + *_window_conds(AdFeedRewardRecord.created_at, date_from, date_to), + ) + .order_by(AdFeedRewardRecord.created_at.desc()) + .limit(fetch) + ).scalars(): + rows.append({ + "source": "feed", + "source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"), + "created_at": rec.created_at, + "ecpm": rec.ecpm_raw, + "coin": rec.coin, + }) + + for rec in db.execute( + select(CoinTransaction) + .where( + CoinTransaction.user_id == user_id, + CoinTransaction.biz_type == "signin", + *_window_conds(CoinTransaction.created_at, date_from, date_to), + ) + .order_by(CoinTransaction.created_at.desc()) + .limit(fetch) + ).scalars(): + rows.append({ + "source": "signin", + "source_label": "签到", + "created_at": rec.created_at, + "ecpm": None, + "coin": rec.amount, + }) + + rows.sort(key=lambda r: r["created_at"], reverse=True) + has_more = len(rows) > offset + limit + return rows[offset:offset + limit], (offset + limit if has_more else None) + + def list_price_reports( db: Session, *, diff --git a/app/admin/routers/feedback_qr.py b/app/admin/routers/feedback_qr.py new file mode 100644 index 0000000..dbb9634 --- /dev/null +++ b/app/admin/routers/feedback_qr.py @@ -0,0 +1,92 @@ +"""admin 反馈页二维码卡配置:读 / 改文案开关 / 上传二维码 / 删二维码(带审计)。 + +整张卡存通用 app_config 表(见 app/repositories/feedback_qr.py),App 反馈页拉 +GET /api/v1/feedback/config 同步。权限:operator 可改(运营维护),super 恒可; +读为只读(任意已登录 admin)。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.feedback_qr import FeedbackQrOut, FeedbackQrUpdate +from app.core import media +from app.models.admin import AdminUser +from app.repositories import feedback_qr + +router = APIRouter( + prefix="/admin/api/feedback-config", + tags=["admin-feedback-config"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=FeedbackQrOut, summary="反馈页二维码卡配置") +def get_config(db: AdminDb) -> FeedbackQrOut: + return FeedbackQrOut(**feedback_qr.get_config(db)) + + +@router.patch("", response_model=FeedbackQrOut, summary="改反馈页文案/开关(带审计)") +def update_config( + body: FeedbackQrUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> FeedbackQrOut: + before, after = feedback_qr.update_config( + db, + enabled=body.enabled, + title=body.title, + group_name=body.group_name, + subtitle=body.subtitle, + admin_id=admin.id, + commit=False, + ) + write_audit( + db, admin, action="feedback_qr.update", target_type="feedback_qr", target_id=None, + detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return FeedbackQrOut(**after) + + +@router.post("/image", response_model=FeedbackQrOut, summary="上传反馈页二维码(带审计)") +async def upload_image( + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, + file: UploadFile = File(...), +) -> FeedbackQrOut: + data = await file.read() + try: + url = media.save_feedback_qr(data) + except media.MediaError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + before, after = feedback_qr.set_image(db, url, admin_id=admin.id, commit=False) + write_audit( + db, admin, action="feedback_qr.set_image", target_type="feedback_qr", target_id=None, + detail={"before": before.get("image_url"), "after": url}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + media.delete_feedback_qr(before.get("image_url")) # 提交成功后再删旧图,避免新图未落库就丢旧图 + return FeedbackQrOut(**after) + + +@router.delete("/image", response_model=FeedbackQrOut, summary="移除反馈页二维码(带审计)") +def delete_image( + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> FeedbackQrOut: + before, after = feedback_qr.set_image(db, None, admin_id=admin.id, commit=False) + write_audit( + db, admin, action="feedback_qr.delete_image", target_type="feedback_qr", target_id=None, + detail={"before": before.get("image_url")}, ip=get_client_ip(request), commit=False, + ) + db.commit() + media.delete_feedback_qr(before.get("image_url")) + return FeedbackQrOut(**after) diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index c631b5d..2e0a017 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -17,6 +17,8 @@ from app.admin.schemas.user import ( GrantCoinsRequest, SetDebugTraceRequest, SetUserStatusRequest, + UserCoinRecord, + UserRewardStats, ) from app.models.admin import AdminUser from app.repositories import user as user_repo @@ -66,6 +68,46 @@ def get_user(user_id: int, db: AdminDb) -> AdminUserOverview: return AdminUserOverview.model_validate(overview) +@router.get( + "/{user_id}/reward-stats", + response_model=UserRewardStats, + summary="用户提现/看广告统计(提现详情用,按时间窗口)", +) +def get_user_reward_stats( + user_id: int, + db: AdminDb, + date_from: Annotated[datetime | None, Query()] = None, + date_to: Annotated[datetime | None, Query()] = None, +) -> UserRewardStats: + """提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。""" + if user_repo.get_user_by_id(db, user_id) is None: + raise HTTPException(status_code=404, detail="用户不存在") + return UserRewardStats( + **queries.user_reward_stats(db, user_id, date_from=date_from, date_to=date_to) + ) + + +@router.get( + "/{user_id}/coin-records", + response_model=CursorPage[UserCoinRecord], + summary="用户金币发放记录(提现详情底部表,按时间窗口分页)", +) +def get_user_coin_records( + user_id: int, + db: AdminDb, + date_from: Annotated[datetime | None, Query()] = None, + date_to: Annotated[datetime | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[UserCoinRecord]: + items, next_cursor = queries.user_coin_records( + db, user_id, date_from=date_from, date_to=date_to, limit=limit, cursor=cursor, + ) + return CursorPage( + items=[UserCoinRecord(**r) for r in items], next_cursor=next_cursor, + ) + + @router.post("/{user_id}/status", response_model=OkResponse, summary="封禁/解封用户") def set_user_status( user_id: int, diff --git a/app/admin/routers/withdraw.py b/app/admin/routers/withdraw.py index c6f7ecc..a36ad54 100644 --- a/app/admin/routers/withdraw.py +++ b/app/admin/routers/withdraw.py @@ -25,6 +25,7 @@ from app.admin.schemas.wallet import ( WithdrawBulkItemResult, WithdrawDetailOut, WithdrawLedgerCheckOut, + WithdrawListItemOut, WithdrawOrderOut, WithdrawRejectRequest, WithdrawSummaryOut, @@ -44,7 +45,7 @@ router = APIRouter( ) -@router.get("", response_model=CursorPage[WithdrawOrderOut], summary="提现单列表") +@router.get("", response_model=CursorPage[WithdrawListItemOut], summary="提现单列表") def list_withdraws( db: AdminDb, user_id: Annotated[int | None, Query()] = None, @@ -63,7 +64,7 @@ def list_withdraws( ] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, -) -> CursorPage[WithdrawOrderOut]: +) -> CursorPage[WithdrawListItemOut]: items, next_cursor, total = queries.list_all_withdraw_orders( db, user_id=user_id, @@ -78,11 +79,16 @@ def list_withdraws( limit=limit, cursor=cursor, ) - return CursorPage( - items=[WithdrawOrderOut.model_validate(o) for o in items], - next_cursor=next_cursor, - total=total, - ) + # 联表带出手机号/昵称 + 累计成功提现(本页 user_id 批量富化,2 条聚合查询,无 N+1)。 + enrichment = queries.withdraw_list_enrichment(db, [o.user_id for o in items]) + _empty = {"phone": None, "nickname": None, "cumulative_success_cents": 0} + out_items = [ + WithdrawListItemOut.model_validate(o).model_copy( + update=enrichment.get(o.user_id, _empty) + ) + for o in items + ] + return CursorPage(items=out_items, next_cursor=next_cursor, total=total) @router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计") diff --git a/app/admin/schemas/feedback_qr.py b/app/admin/schemas/feedback_qr.py new file mode 100644 index 0000000..93dd7f2 --- /dev/null +++ b/app/admin/schemas/feedback_qr.py @@ -0,0 +1,22 @@ +"""admin 反馈页二维码卡配置 schemas。""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class FeedbackQrOut(BaseModel): + enabled: bool + image_url: str | None = None + title: str + group_name: str + subtitle: str + updated_at: str | None = None + + +class FeedbackQrUpdate(BaseModel): + """改文案/开关(均可选,只改传了的字段;图片走单独的上传/删除接口)。""" + + enabled: bool | None = None + title: str | None = Field(default=None, max_length=40) + group_name: str | None = Field(default=None, max_length=40) + subtitle: str | None = Field(default=None, max_length=60) diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 6f1c352..fad0a62 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -37,6 +37,36 @@ class AdminUserOverview(BaseModel): feedback_total: int +class UserRewardStats(BaseModel): + """提现详情抽屉「用户统计区」:按时间窗口算的提现 + 看广告行为统计。 + + 窗口由 date_from/date_to 决定(都不传 = 注册至今 = 全量);现金余额除外——它是当前快照。 + 各「*_cash_cents」是把该来源累计发放金币折算成可提现现金(分):100 金币 = 1 分(COIN_PER_YUAN=10000)。 + eCPM 单位沿用穿山甲原值「分/千次」。 + """ + + withdraw_success_cents: int # 累计提现(窗口内 success 金额) + cash_balance_cents: int # 现金余额(当前快照,不随窗口) + withdraw_total: int # 提现总次数(窗口内全部状态) + traditional_task_cash_cents: int # 传统任务提现(非广告非人工的金币折现) + reward_video_count: int # 累计激励视频数(granted 条数) + reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次) + reward_video_cash_cents: int # 激励视频提现(金币折现) + feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加) + feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次) + feed_cash_cents: int # 信息流广告提现(金币折现) + + +class UserCoinRecord(BaseModel): + """金币发放记录(提现详情底部表)。source 见 source_label;非广告来源 ecpm 为 None。""" + + source: str # reward_video / signin_boost / feed / signin + source_label: str # 激励视频 / 签到膨胀 / 信息流广告 / 签到 + created_at: datetime + ecpm: str | None = None # 原始 eCPM(分/千次),非广告为 None + coin: int # 发放金币数 + + def _strip_reason(v: str) -> str: # min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因 if not v.strip(): diff --git a/app/admin/schemas/wallet.py b/app/admin/schemas/wallet.py index e2e9874..10cc8ed 100644 --- a/app/admin/schemas/wallet.py +++ b/app/admin/schemas/wallet.py @@ -50,6 +50,17 @@ class WithdrawOrderOut(BaseModel): updated_at: datetime +class WithdrawListItemOut(WithdrawOrderOut): + """提现单列表项:在提现单字段基础上,补本页用户的手机号/昵称 + 累计成功提现金额(分)。 + + 仅列表接口用(需联表 User + 聚合);详情/批量仍用 WithdrawOrderOut(不带这些字段)。 + """ + + phone: str | None = None + nickname: str | None = None + cumulative_success_cents: int = 0 # 累计成功提现 = SUM(amount_cents) WHERE status='success' + + class WithdrawSummaryOut(BaseModel): reviewing_count: int reviewing_amount_cents: int diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index cf866b2..79dbcfd 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -372,6 +372,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, + feed_scene=payload.feed_scene, app_env=payload.app_env, our_code_id=payload.our_code_id, aborted=payload.aborted, diff --git a/app/api/v1/feedback.py b/app/api/v1/feedback.py index e59fb54..79f1a61 100644 --- a/app/api/v1/feedback.py +++ b/app/api/v1/feedback.py @@ -14,7 +14,8 @@ from fastapi import APIRouter, File, Form, HTTPException, 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.repositories import feedback_qr as feedback_qr_repo +from app.schemas.feedback import FeedbackOut, FeedbackQrConfigOut logger = logging.getLogger("shagua.feedback") @@ -60,3 +61,9 @@ async def submit_feedback( ) logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls)) return FeedbackOut.model_validate(fb) + + +@router.get("/config", response_model=FeedbackQrConfigOut, summary="反馈页二维码卡配置") +def feedback_config(user: CurrentUser, db: DbSession) -> FeedbackQrConfigOut: + """运营后台配的反馈页「加群二维码」卡(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取。""" + return FeedbackQrConfigOut(**feedback_qr_repo.get_config(db)) diff --git a/app/core/config.py b/app/core/config.py index 76621e1..0520923 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -129,10 +129,14 @@ class Settings(BaseSettings): WITHDRAW_AUTO_RECONCILE_ENABLED: bool = False WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC: int = 300 WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES: int = 15 - # 0 点自动兑金币(由 deploy/daily-exchange.timer 触发 scripts.daily_auto_exchange)。 - # 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停可在 .env 置 false, - # 无需动 systemd timer——脚本读此开关,false 时直接 no-op 退出。 + # 0 点自动兑金币:由 App 进程内任务 app.core.daily_exchange_worker 跨 0 点跑一轮 + # wallet.daily_auto_exchange(原 deploy/daily-exchange.timer 已不再需要,见 deploy/daily-exchange.md)。 + # 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停在 .env 置 false + # (worker 不启动;脚本也 no-op)。 AUTO_EXCHANGE_ENABLED: bool = True + # 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。 + # 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。 + AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600 # 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。 # 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容 # (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。 diff --git a/app/core/daily_exchange_worker.py b/app/core/daily_exchange_worker.py new file mode 100644 index 0000000..24b22e4 --- /dev/null +++ b/app/core/daily_exchange_worker.py @@ -0,0 +1,125 @@ +"""0 点金币→现金自动兑换的进程内定时任务。 + +替代原 systemd timer(deploy/daily-exchange.*):App 启动即自带,每 +`AUTO_EXCHANGE_CHECK_INTERVAL_SEC` 醒一次,跨进北京新的一天(0 点后)就跑一轮 +`wallet.daily_auto_exchange`。 + +健壮性: +- **逐用户幂等**:当天已有 exchange_in 流水的用户跳过(见 wallet._has_exchange_in_on), + 故启动补跑 / 多次唤醒 / 进程重启都安全,不会重复兑。 +- **当天首跑即补**:进程起来时若当天还没兑过,立即兑一轮(等价原 timer 的 Persistent 补跑)。 +- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑(防跨进程并发导致 TOCTOU 双兑)。 +- **开关**:settings.AUTO_EXCHANGE_ENABLED=false 时不启动(与脚本/原 timer 同一开关)。 +""" +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 wallet as wallet_repo + +logger = logging.getLogger("shagua.daily_exchange") +_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "daily_exchange.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 _exchange_once() -> dict: + with SessionLocal() as db: + return wallet_repo.daily_auto_exchange(db) + + +async def _run_loop() -> None: + interval = max(60, int(settings.AUTO_EXCHANGE_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("daily auto-exchange skipped: another worker owns lock") + return + await _run_locked_loop(interval) + + +async def _run_locked_loop(interval: int) -> None: + logger.info("daily auto-exchange worker started interval=%ss", interval) + # 本进程上次跑过的北京日;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(_exchange_once) + last_run = today + logger.info("daily auto-exchange done date=%s result=%s", today, result) + except SQLAlchemyError: + logger.exception("daily auto-exchange db error") + except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出 + logger.exception("daily auto-exchange unexpected error") + await asyncio.sleep(interval) + except asyncio.CancelledError: + logger.info("daily auto-exchange worker stopped") + raise + + +def start_daily_exchange_worker() -> asyncio.Task | None: + if not settings.AUTO_EXCHANGE_ENABLED: + logger.info("daily auto-exchange disabled (AUTO_EXCHANGE_ENABLED=false)") + return None + return asyncio.create_task(_run_loop(), name="daily-auto-exchange") + + +async def stop_daily_exchange_worker(task: asyncio.Task | None) -> None: + if task is None: + return + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task diff --git a/app/core/media.py b/app/core/media.py index accce69..192b186 100644 --- a/app/core/media.py +++ b/app/core/media.py @@ -37,7 +37,7 @@ def _sniff_ext(data: bytes) -> str | None: return None -def _save_image(subdir: str, user_id: int, data: bytes) -> str: +def _save_named(subdir: str, name_prefix: str, data: bytes) -> str: """通用图片落盘:校验非空 / ≤上限 / 魔数为图片,随机文件名,返回相对 URL。""" if not data: raise MediaError("空文件") @@ -47,11 +47,16 @@ def _save_image(subdir: str, user_id: int, data: bytes) -> str: if ext is None: raise MediaError("仅支持 JPEG / PNG / WebP 图片") - fname = f"u{user_id}_{secrets.token_hex(8)}{ext}" + fname = f"{name_prefix}_{secrets.token_hex(8)}{ext}" (_media_dir(subdir) / fname).write_bytes(data) return f"{settings.MEDIA_URL_PREFIX}/{subdir}/{fname}" +def _save_image(subdir: str, user_id: int, data: bytes) -> str: + """用户上传图片落盘(文件名带 user_id 前缀)。""" + return _save_named(subdir, f"u{user_id}", data) + + def save_avatar(user_id: int, data: bytes) -> str: """保存头像,返回相对 URL(`/media/avatars/`)。""" return _save_image("avatars", user_id, data) @@ -67,6 +72,11 @@ def save_report_image(user_id: int, data: bytes) -> str: return _save_image("price_report", user_id, data) +def save_feedback_qr(data: bytes) -> str: + """保存反馈页二维码(运营后台上传的运营素材,非用户文件),返回相对 URL(`/media/feedback_qr/`)。""" + return _save_named("feedback_qr", "qr", data) + + def save_cps_image(admin_id: int, data: bytes) -> str: """保存 CPS 活动落地页图,返回相对 URL(`/media/cps/`)。admin_id 入文件名便于追溯。""" return _save_image("cps", admin_id, data) @@ -84,15 +94,25 @@ def to_abs_media_url(rel_url: str | None) -> str | None: return f"{base}{rel_url}" if base else rel_url -def delete_avatar(url: str | None) -> None: - """删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。""" - prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/" +def _delete_managed(subdir: str, url: str | None) -> None: + """删除本服务托管的某子目录下文件;外部 URL / 空值 / 非本子目录的不处理。""" + prefix = f"{settings.MEDIA_URL_PREFIX}/{subdir}/" if not url or not url.startswith(prefix): return fname = url[len(prefix):] if not fname or "/" in fname or "\\" in fname or ".." in fname: return # 防路径穿越 try: - (_media_dir("avatars") / fname).unlink(missing_ok=True) + (_media_dir(subdir) / fname).unlink(missing_ok=True) except OSError: pass + + +def delete_avatar(url: str | None) -> None: + """删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。""" + _delete_managed("avatars", url) + + +def delete_feedback_qr(url: str | None) -> None: + """删除本服务托管的旧反馈页二维码文件;外部 URL 或空值不处理。""" + _delete_managed("feedback_qr", url) diff --git a/app/main.py b/app/main.py index 17ff332..af011f8 100644 --- a/app/main.py +++ b/app/main.py @@ -38,6 +38,10 @@ 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.config import settings +from app.core.daily_exchange_worker import ( + start_daily_exchange_worker, + stop_daily_exchange_worker, +) from app.core.logging import setup_logging from app.core.withdraw_reconcile_worker import ( start_withdraw_reconcile_worker, @@ -59,10 +63,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings.DATABASE_URL.split("://", 1)[0], ) reconcile_task = start_withdraw_reconcile_worker() + daily_exchange_task = start_daily_exchange_worker() try: yield finally: await stop_withdraw_reconcile_worker(reconcile_task) + await stop_daily_exchange_worker(daily_exchange_task) logger.info("shutting down") diff --git a/app/models/ad_feed_reward.py b/app/models/ad_feed_reward.py index fb1ff82..55acbee 100644 --- a/app/models/ad_feed_reward.py +++ b/app/models/ad_feed_reward.py @@ -30,6 +30,9 @@ class AdFeedRewardRecord(Base): ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) adn: Mapped[str | None] = mapped_column(String(32), nullable=True) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流 + # 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。 + feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True) # 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。 app_env: Mapped[str | None] = mapped_column(String(16), nullable=True) our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True) diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index 64fe277..f5912ac 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -69,6 +69,7 @@ def grant_feed_reward( ad_session_id: str | None = None, adn: str | None = None, slot_id: str | None = None, + feed_scene: str | None = None, app_env: str | None = None, our_code_id: str | None = None, aborted: bool = False, @@ -82,6 +83,7 @@ def grant_feed_reward( duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。 + feed_scene:点位场景(comparison/coupon/welfare),仅做归类落库,不参与发奖计算。 """ existing = _find_by_event(db, client_event_id) if existing is not None: @@ -104,6 +106,7 @@ def grant_feed_reward( ecpm_raw=ecpm, adn=adn, slot_id=slot_id, + feed_scene=feed_scene, app_env=app_env, our_code_id=our_code_id, coin=0, @@ -122,6 +125,7 @@ def grant_feed_reward( ecpm_raw=ecpm, adn=adn, slot_id=slot_id, + feed_scene=feed_scene, app_env=app_env, our_code_id=our_code_id, coin=0, @@ -141,6 +145,7 @@ def grant_feed_reward( ecpm_raw=ecpm, adn=adn, slot_id=slot_id, + feed_scene=feed_scene, app_env=app_env, our_code_id=our_code_id, coin=0, @@ -165,6 +170,7 @@ def grant_feed_reward( ecpm_raw=ecpm, adn=adn, slot_id=slot_id, + feed_scene=feed_scene, app_env=app_env, our_code_id=our_code_id, coin=coin, diff --git a/app/repositories/feedback_qr.py b/app/repositories/feedback_qr.py new file mode 100644 index 0000000..0804b9f --- /dev/null +++ b/app/repositories/feedback_qr.py @@ -0,0 +1,106 @@ +"""反馈页二维码卡配置读写(运营后台可配 → App 反馈页同步)。 + +整张卡(开关 + 二维码图 + 三行文案)作为一个 JSON 对象,复用通用 app_config 表 +(key=feedback_qr_card,value 存这个 dict)。表里没这行 → 返回内置默认(= App 反馈页原 +硬编码文案,空配置 = 改造前现状,图片走 App 本地兜底)。admin 改 → 写一行 → App 下次拉 +GET /api/v1/feedback/config 即生效(跨进程一致)。 + +不经 app.repositories.app_config(那层按 CONFIG_DEFS 白名单限定 key,且会出现在系统配置页); +本 key 由本模块独占维护,不进 CONFIG_DEFS,所以不会污染系统配置页。 +""" +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from app.models.app_config import AppConfig + +_KEY = "feedback_qr_card" + +# 默认值镜像 App 反馈页原硬编码:空配置时整体行为与改造前一致。 +_DEFAULTS: dict[str, Any] = { + "enabled": True, + "image_url": None, # None = App 用本地 asset 兜底(group_qr.png),再不行画占位伪码 + "title": "长按图片保存二维码", + "group_name": "傻瓜比价官方群", + "subtitle": "一起唠嗑共创、解锁新玩法", +} + +_FIELDS = tuple(_DEFAULTS.keys()) + + +def _merge(raw: Any) -> dict[str, Any]: + """把 DB 存的(可能不全的)dict 叠加到默认上,得到完整配置(5 个字段,无 updated_at)。""" + out = dict(_DEFAULTS) + if isinstance(raw, dict): + for k in _FIELDS: + v = raw.get(k) + if v is not None: # image_url 的 None 与默认 None 等价,无需特判 + out[k] = v + return out + + +def get_config(db: Session) -> dict[str, Any]: + """完整配置 + updated_at(admin 读 / App 读共用;App schema 会忽略多余的 updated_at)。""" + row = db.get(AppConfig, _KEY) + cfg = _merge(row.value if row is not None else None) + cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None + return cfg + + +def _write(db: Session, value: dict[str, Any], *, admin_id: int, commit: bool) -> dict[str, Any]: + """整体覆写该行(value 须为完整 5 字段 dict),返回合并后的完整配置(含 updated_at)。""" + row = db.get(AppConfig, _KEY) + if row is None: + row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id) + db.add(row) + else: + row.value = value # 重新赋整个 dict,SQLAlchemy 能侦测到变更(in-place 改才侦测不到) + row.updated_by_admin_id = admin_id + if commit: + db.commit() + db.refresh(row) + else: + db.flush() + out = _merge(row.value) + out["updated_at"] = row.updated_at.isoformat() if row.updated_at else None + return out + + +def update_config( + db: Session, + *, + enabled: bool | None = None, + title: str | None = None, + group_name: str | None = None, + subtitle: str | None = None, + admin_id: int, + commit: bool = True, +) -> tuple[dict[str, Any], dict[str, Any]]: + """改文案/开关(只改传了的字段;图片走 set_image)。返回 (before, after) 供审计。""" + row = db.get(AppConfig, _KEY) + before = _merge(row.value if row is not None else None) + new_value = {k: before[k] for k in _FIELDS} + if enabled is not None: + new_value["enabled"] = enabled + if title is not None: + new_value["title"] = title.strip() + if group_name is not None: + new_value["group_name"] = group_name.strip() + if subtitle is not None: + new_value["subtitle"] = subtitle.strip() + after = _write(db, new_value, admin_id=admin_id, commit=commit) + return before, after + + +def set_image( + db: Session, image_url: str | None, *, admin_id: int, commit: bool = True +) -> tuple[dict[str, Any], dict[str, Any]]: + """设置/清空二维码图 URL。返回 (before, after);before['image_url'] 供调用方删旧文件。""" + row = db.get(AppConfig, _KEY) + before = _merge(row.value if row is not None else None) + new_value = {k: before[k] for k in _FIELDS} + new_value["image_url"] = image_url + after = _write(db, new_value, admin_id=admin_id, commit=commit) + return before, after diff --git a/app/schemas/ad.py b/app/schemas/ad.py index ae56aab..b7feccf 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -135,6 +135,12 @@ class FeedRewardIn(BaseModel): duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)") adn: str | None = Field(None, description="实际投放 ADN") slot_id: str | None = Field(None, description="实际展示代码位") + feed_scene: str | None = Field( + None, + max_length=16, + description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);" + "比价与领券共用同一信息流代码位,需客户端在各调用点显式标注,缺省=未分类", + ) app_env: str | None = Field( None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" ) diff --git a/app/schemas/feedback.py b/app/schemas/feedback.py index 729025c..4938a74 100644 --- a/app/schemas/feedback.py +++ b/app/schemas/feedback.py @@ -12,3 +12,17 @@ class FeedbackOut(BaseModel): id: int status: str created_at: datetime + + +class FeedbackQrConfigOut(BaseModel): + """反馈页二维码卡配置(运营后台可配)。App 反馈页据此渲染整张「加群二维码」卡。 + + image_url 是相对 /media 路径(客户端按自己的 BASE_URL 拼绝对地址),为空 → 客户端走本地兜底。 + """ + + enabled: bool + image_url: str | None = None + title: str + group_name: str + subtitle: str + diff --git a/deploy/daily-exchange.md b/deploy/daily-exchange.md index 70c4b71..794257f 100644 --- a/deploy/daily-exchange.md +++ b/deploy/daily-exchange.md @@ -3,6 +3,15 @@ > 对象:维护「每天 0 点把用户金币自动兑成现金」这套定时任务的同事。 > 🔒 服务器登录信息见**私密交接清单**,不入库。 +## 现状(2026-06 起):已改为 App 进程内任务,不再需要 systemd timer +「0 点自动兑换」现由 **App 进程内后台任务** `app.core.daily_exchange_worker` 负责:App 一起来就 +每 10 分钟检查、跨过北京 0 点自动跑一轮(逐用户幂等、文件锁互斥、重启补跑当天遗漏)。**无需再装 / 启用 +`daily-exchange.timer`**。 + +- 开关仍是 `.env` 的 `AUTO_EXCHANGE_ENABLED`(false → worker 不启动);间隔由 `AUTO_EXCHANGE_CHECK_INTERVAL_SEC`(默认 600s=10min)控。 +- 下方 systemd timer / 脚本属**遗留 + 手动应急**:逻辑同一套且幂等,可手动 `--once` 补跑; + 但**不要再 `enable` timer 与进程内 worker 并存**(虽幂等不会双兑,纯属多余)。 + ## 它是什么 客户端已删手动「兑金币」入口、改为「0 点自动兑现金(可能存在延迟)」,故服务端每天 0 点跑一轮: 把每个用户的金币按汇率 **100 金币 = 1 分** 兑成现金。 @@ -21,8 +30,9 @@ | systemd 单元 | `deploy/daily-exchange.{service,timer}` | | 运行锁 | `/tmp/daily_auto_exchange.lock`(可用环境变量 `DAILY_EXCHANGE_LOCK` 覆盖) | -## 部署(Linux 服务器) +## (遗留 / 可选)systemd timer 部署 — 现已被进程内 worker 取代,正常无需执行 ```bash +# ⚠️ 仅在你明确想用外部 timer 而非进程内 worker 时才做(并把 worker 关掉),否则跳过。 sudo cp deploy/daily-exchange.{service,timer} /etc/systemd/system/ sudo systemctl daemon-reload && sudo systemctl enable --now daily-exchange.timer systemctl list-timers daily-exchange.timer # 确认下次触发时间 diff --git a/scripts/seed_mock_price_reports.py b/scripts/seed_mock_price_reports.py new file mode 100644 index 0000000..6ca04fe --- /dev/null +++ b/scripts/seed_mock_price_reports.py @@ -0,0 +1,208 @@ +"""一次性 mock:造几条带「真实可加载截图」的上报更低价记录,供后台「上报审核」页验收图片加载。 + +为什么要真实图片:验证「截图加载」bug 是否修好,必须让 images 指向**真实存在于 /media 的文件**, +否则 404 是「文件不存在」而非「前端取图地址不对」,无法区分。本脚本: + 1. 在 data/media/price_report/ 生成几张纯色 PNG(手写 PNG 字节,无需 Pillow)作为截图; + 2. 造 3 个 mock 用户 + 覆盖 待审核 / 已通过 / 已拒绝 的上报记录,引用这些图。 + +幂等:重跑先按固定 mock 手机号清掉旧用户/上报 + 删 mock 图再重建;--clean-only 只清。 + + python -m scripts.seed_mock_price_reports + python -m scripts.seed_mock_price_reports --clean-only + +看图:前端「上报审核」页(http://localhost:3001 → 上报审核)。图经 NEXT_PUBLIC_MEDIA_BASE +(本地 = http://localhost:8770)由 App 后端 /media 加载——改过 .env.local 后需重启 next dev, +且 App 后端(:8770)要在跑。 +""" +from __future__ import annotations + +import argparse +import struct +import sys +import zlib +from datetime import datetime, timedelta +from pathlib import Path + +from sqlalchemy import delete, select + +from app.core import rewards +from app.core.config import settings +from app.db.session import SessionLocal +from app.models.price_report import PriceReport +from app.models.user import User + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥ + +# 固定 mock 手机号:脚本只动这些号,便于幂等重建/清理。 +MOCK_PHONES = ["13266660001", "13266660002", "13266660003"] + +_REPORT_DIR = Path(settings.MEDIA_ROOT) / "price_report" +_MOCK_IMG_GLOB = "mock_pr_*.png" # 本脚本生成的图前缀,清理时按此删 + + +def _solid_png(width: int, height: int, rgb: tuple[int, int, int]) -> bytes: + """生成一张纯色 PNG(truecolor RGB)的字节,无需 Pillow。颜色块即可肉眼判断「图加载出来了」。""" + def _chunk(typ: bytes, data: bytes) -> bytes: + body = typ + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8bit/通道, color type 2 = RGB + row = b"\x00" + bytes(rgb) * width # 每行前缀 filter byte 0 + idat = zlib.compress(row * height, 9) + return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"") + + +def _write_mock_image(name: str, rgb: tuple[int, int, int]) -> str: + """写一张 mock 截图到 media/price_report/,返回其相对 URL(/media/price_report/)。""" + _REPORT_DIR.mkdir(parents=True, exist_ok=True) + (_REPORT_DIR / name).write_bytes(_solid_png(320, 320, rgb)) + return f"{settings.MEDIA_URL_PREFIX}/price_report/{name}" + + +def _bj_now() -> datetime: + """北京 wall-clock(naive),与 report_repo.create_report 一致(前端按北京时间解析,不加 Z)。""" + return datetime.now(rewards.CN_TZ).replace(tzinfo=None) + + +def clean(db) -> int: + uids = list(db.execute(select(User.id).where(User.phone.in_(MOCK_PHONES))).scalars()) + if uids: + db.execute(delete(PriceReport).where(PriceReport.user_id.in_(uids))) + db.execute(delete(User).where(User.id.in_(uids))) + db.commit() + # 删 mock 截图文件 + if _REPORT_DIR.exists(): + for f in _REPORT_DIR.glob(_MOCK_IMG_GLOB): + f.unlink(missing_ok=True) + return len(uids) + + +def seed(db) -> list[PriceReport]: + now = _bj_now() + + # 1) 建 3 个 mock 用户 + users: dict[str, User] = {} + for i, (phone, nickname) in enumerate( + zip(MOCK_PHONES, ["省钱小王", "比价老李", "薅羊毛阿珍"]) + ): + u = User( + phone=phone, + nickname=nickname, + register_channel="sms", + status="active", + created_at=now - timedelta(days=8 + i), + last_login_at=now - timedelta(hours=3), + ) + db.add(u) + users[phone] = u + db.flush() # 拿 user.id + + # 2) 生成 mock 截图(不同颜色块,便于肉眼区分「都加载出来了」) + palette = [ + (250, 173, 20), # 橙 + (82, 196, 26), # 绿 + (24, 144, 255), # 蓝 + (114, 46, 209), # 紫 + (245, 34, 45), # 红 + ] + imgs = [_write_mock_image(f"mock_pr_{i}.png", palette[i]) for i in range(len(palette))] + + # 3) 造上报记录(覆盖 pending / approved / rejected 三个 tab,每条都带可加载截图) + def report( + phone: str, + store: str, + dish: str, + orig_name: str, + orig_cents: int, + rep_id: str, + rep_name: str, + rep_cents: int, + images: list[str], + *, + status: str = "pending", + reward_coins: int | None = None, + reject_reason: str | None = None, + created: datetime, + ) -> PriceReport: + return PriceReport( + user_id=users[phone].id, + comparison_record_id=None, # 可空:不依赖真实比价记录 + store_name=store, + dish_summary=dish, + original_platform_id="meituan-waimai", + original_platform_name=orig_name, + original_price_cents=orig_cents, + reported_platform_id=rep_id, + reported_platform_name=rep_name, + reported_price_cents=rep_cents, + images=images, + status=status, + reward_coins=reward_coins, + reject_reason=reject_reason, + reviewed_at=(created + timedelta(hours=1)) if status != "pending" else None, + created_at=created, + ) + + reports = [ + # 待审核(默认 tab,重点验图):2 图 + 1 图各一条 + report("13266660001", "瑞幸咖啡(国贸店)", "生椰拿铁 × 2", "美团外卖", 3980, + "jd-waimai", "京东外卖", 3200, [imgs[0], imgs[1]], + created=now - timedelta(minutes=8)), + report("13266660002", "麦当劳(三里屯店)", "巨无霸套餐", "美团外卖", 4200, + "taobao-shanguang", "淘宝闪购", 3550, [imgs[2]], + created=now - timedelta(minutes=35)), + # 已通过(已发金币) + report("13266660003", "蜜雪冰城(西单店)", "柠檬水 × 3", "美团外卖", 1800, + "jd-waimai", "京东外卖", 1500, [imgs[3]], + status="approved", reward_coins=1000, created=now - timedelta(days=1)), + # 已拒绝(带理由) + report("13266660001", "星巴克(望京店)", "美式 × 1", "美团外卖", 3000, + "jd-waimai", "京东外卖", 2800, [imgs[4]], + status="rejected", reject_reason="截图不清晰", created=now - timedelta(days=2)), + ] + db.add_all(reports) + db.commit() + for r in reports: + db.refresh(r) + return reports + + +def _yuan(cents: int | None) -> str: + return "-" if cents is None else f"¥{cents / 100:.2f}" + + +def main() -> None: + parser = argparse.ArgumentParser(description="造上报更低价 mock 数据(后台上报审核页验图用)") + parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建") + args = parser.parse_args() + + db = SessionLocal() + try: + removed = clean(db) + if removed: + print(f"🧹 已清理旧 mock:{removed} 个用户及其上报记录 + mock 截图") + if args.clean_only: + print("✅ 仅清理,已完成。") + return + + reports = seed(db) + label = {"pending": "待审核", "approved": "已通过", "rejected": "已拒绝"} + print(f"\n✅ 已生成 {len(reports)} 条上报记录(截图落 {_REPORT_DIR}):") + for r in reports: + print( + f" #{r.id} [{label.get(r.status, r.status)}] {r.store_name} | " + f"{r.original_platform_name}{_yuan(r.original_price_cents)} → " + f"{r.reported_platform_name}{_yuan(r.reported_price_cents)} | {len(r.images)}图" + ) + print( + "\n👉 打开 http://localhost:3001 → 上报审核 查看(默认「待审核」tab)。" + "\n 图加载不出来时排查:① 是否重启过 next dev(读 .env.local 的 NEXT_PUBLIC_MEDIA_BASE)" + " ② App 后端(:8770)是否在跑(它托管 /media)。" + ) + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/seed_mock_withdraws.py b/scripts/seed_mock_withdraws.py new file mode 100644 index 0000000..ad45f29 --- /dev/null +++ b/scripts/seed_mock_withdraws.py @@ -0,0 +1,296 @@ +"""一次性 mock:造几条不同状态的提现单,供运营后台「提现管理」页联调验收。 + +覆盖: + - 全部 5 个状态 tab(待审核 / 打款中 / 已到账 / 已拒绝 / 打款失败) + - 「累计提现」列(按该用户 success 单金额求和;有非零也有 0) + - 空昵称(展示 "-")、提现实名、失败/拒绝原因 + - 配套双分录现金流水(withdraw / withdraw_refund / exchange_in)+ 账户余额, + 让顶部「账本校验」保持绿色、详情抽屉的现金余额/流水也真实。 + +约束:withdraw_order 有部分唯一索引(同一 user 在 reviewing/pending 最多 1 单), +本脚本每个 mock 用户至多 1 个活动单,满足约束。 + +幂等:每次运行先按固定 mock 手机号清掉旧 mock 再重建。仅清理用 --clean-only。 + + .venv\\Scripts\\python.exe scripts\\seed_mock_withdraws.py + .venv\\Scripts\\python.exe scripts\\seed_mock_withdraws.py --clean-only +""" +from __future__ import annotations + +import argparse +import sys +import uuid +from collections import defaultdict +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete, select + +from app.core import rewards +from app.db.session import SessionLocal +from app.models.ad_feed_reward import AdFeedRewardRecord +from app.models.ad_reward import AdRewardRecord +from app.models.user import User +from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder + +# Windows GBK 控制台下也能正常打印中文(避免 UnicodeEncodeError) +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +# 固定 mock 手机号:脚本只动这些号,便于幂等重建 / 清理。 +MOCK_PHONES = [ + "13800000001", + "13900000002", + "13700000003", + "13600000004", + "13500000005", + "13312345678", +] + +REMARK = {"exchange_in": "金币兑入", "withdraw": "提现扣款", "withdraw_refund": "提现退款"} + + +def _naive_utc_now() -> datetime: + """与 func.now() 在 SQLite 的口径一致:naive UTC。前端按 UTC 解析再转北京时间。""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _mock_transfer_no() -> str: + """模拟微信转账单号(长串数字)。""" + return "1330" + str(uuid.uuid4().int)[:26] + + +def clean(db) -> int: + uids = list(db.execute(select(User.id).where(User.phone.in_(MOCK_PHONES))).scalars()) + if not uids: + return 0 + for model in ( + WithdrawOrder, + CashTransaction, + CoinTransaction, + CoinAccount, + AdRewardRecord, + AdFeedRewardRecord, + ): + db.execute(delete(model).where(model.user_id.in_(uids))) + db.execute(delete(User).where(User.id.in_(uids))) + db.commit() + return len(uids) + + +def seed(db) -> list[WithdrawOrder]: + now = _naive_utc_now() + + def ago(**kw) -> datetime: + return now - timedelta(**kw) + + # 1) 建用户(手机号 / 昵称;U4 昵称留空测 "-" 展示) + users_spec = [ + ("13800000001", "省钱小能手"), + ("13900000002", "薅羊毛达人"), + ("13700000003", "比价狂魔"), + ("13600000004", None), + ("13500000005", "退款体验官"), + ("13312345678", "已到账老用户"), + ] + users: dict[str, User] = {} + for phone, nickname in users_spec: + u = User( + phone=phone, + nickname=nickname, + register_channel="sms", + status="active", + created_at=ago(days=12), + last_login_at=ago(hours=2), + ) + db.add(u) + users[phone] = u + db.flush() # 拿 user.id + + # 2) 建提现单(覆盖各状态) + def order( + phone: str, + cents: int, + status: str, + *, + name: str | None = None, + wechat: str | None = None, + transfer: str | None = None, + fail: str | None = None, + created: datetime, + updated: datetime | None = None, + ) -> WithdrawOrder: + return WithdrawOrder( + user_id=users[phone].id, + out_bill_no=uuid.uuid4().hex, # 与线上同格式:uuid4 的 32 位 hex + amount_cents=cents, + user_name=name, + status=status, + wechat_state=wechat, + transfer_bill_no=transfer, + fail_reason=fail, + created_at=created, + updated_at=updated or created, + ) + + orders = [ + # U1 省钱小能手:2 已到账 + 1 待审核 → 累计提现 ¥18.00 + order("13800000001", 1000, "success", name="张伟", wechat="SUCCESS", + transfer=_mock_transfer_no(), created=ago(days=3)), + order("13800000001", 800, "success", name="张伟", wechat="SUCCESS", + transfer=_mock_transfer_no(), created=ago(days=5)), + order("13800000001", 500, "reviewing", name="张伟", created=ago(minutes=3)), + # U2 薅羊毛达人:1 已到账 + 1 待审核 → 累计提现 ¥20.00 + order("13900000002", 2000, "success", name="李娜", wechat="SUCCESS", + transfer=_mock_transfer_no(), created=ago(days=2)), + order("13900000002", 1250, "reviewing", name="李娜", created=ago(minutes=25)), + # U3 比价狂魔:1 待审核 → 累计提现 ¥0 + order("13700000003", 100, "reviewing", name="王芳", created=ago(hours=1)), + # U4(空昵称):1 打款中 → 累计提现 ¥0 + order("13600000004", 300, "pending", name="刘洋", wechat="ACCEPTED", + transfer=_mock_transfer_no(), created=ago(minutes=40), updated=ago(minutes=20)), + # U5 退款体验官:1 已拒绝 + 1 打款失败 → 累计提现 ¥0 + order("13500000005", 5000, "rejected", name="陈静", fail="微信实名不匹配", + created=ago(days=1)), + order("13500000005", 3000, "failed", name="陈静", wechat="FAIL", + transfer=_mock_transfer_no(), fail="收款账户异常,已退回余额", + created=ago(hours=6), updated=ago(hours=5)), + # U6 已到账老用户:2 已到账 → 累计提现 ¥40.00 + order("13312345678", 1500, "success", name="赵强", wechat="SUCCESS", + transfer=_mock_transfer_no(), created=ago(days=10)), + order("13312345678", 2500, "success", name="赵强", wechat="SUCCESS", + transfer=_mock_transfer_no(), created=ago(days=8)), + ] + db.add_all(orders) + db.flush() + + # 3) 配套双分录现金流水 + 账户余额(让账本校验绿、详情抽屉真实): + # 每单创建时扣款(withdraw, -amount, ref=out_bill_no);失败/拒绝再退款(withdraw_refund, +amount); + # 每用户起始补一笔 exchange_in 把余额垫正;CoinAccount.cash 余额 = 该用户流水净额。 + by_user: dict[int, list[WithdrawOrder]] = defaultdict(list) + for o in orders: + by_user[o.user_id].append(o) + + for uid, uorders in by_user.items(): + initial = sum(o.amount_cents for o in uorders) + 2000 # 垫 ¥20 余额,确保全程非负 + earliest = min(o.created_at for o in uorders) + events: list[tuple[datetime, str, int, str | None]] = [ + (earliest - timedelta(days=1), "exchange_in", initial, None) + ] + for o in uorders: + events.append((o.created_at, "withdraw", -o.amount_cents, o.out_bill_no)) + if o.status in ("failed", "rejected"): + events.append((o.updated_at, "withdraw_refund", o.amount_cents, o.out_bill_no)) + events.sort(key=lambda e: e[0]) + + bal = 0 + for t, biz, amt, ref in events: + bal += amt + db.add(CashTransaction( + user_id=uid, amount_cents=amt, balance_after_cents=bal, + biz_type=biz, ref_id=ref, remark=REMARK[biz], created_at=t, + )) + db.add(CoinAccount(user_id=uid, coin_balance=0, cash_balance_cents=bal, total_coin_earned=0)) + + # 4) 给部分用户造看广告/签到记录(喂提现详情抽屉的「统计区」+「金币记录」表)。 + # eCPM 原值单位=分/千次;金币用与线上同源的 rewards.calculate_ad_reward_coin 复算,真实偏小。 + def _bj_date(t: datetime) -> str: + return (t + timedelta(hours=8)).date().isoformat() + + coin_bal: dict[int, int] = defaultdict(int) + + def add_video(uid: int, ecpm_fen: int, created: datetime, nth: int, scene: str = "reward_video") -> None: + db.add(AdRewardRecord( + trans_id=uuid.uuid4().hex, user_id=uid, + coin=rewards.calculate_ad_reward_coin(str(ecpm_fen), nth), + status="granted", reward_scene=scene, ecpm_raw=str(ecpm_fen), + reward_date=_bj_date(created), created_at=created, + )) + + def add_feed(uid: int, ecpm_fen: int, units: int, created: datetime, scene: str | None = None) -> None: + coin = sum(rewards.calculate_ad_reward_coin(str(ecpm_fen), i) for i in range(1, units + 1)) + db.add(AdFeedRewardRecord( + client_event_id=uuid.uuid4().hex, user_id=uid, reward_date=_bj_date(created), + duration_seconds=units * 10, unit_count=units, ecpm_raw=str(ecpm_fen), + feed_scene=scene, coin=coin, status="granted", created_at=created, + )) + + def add_signin(uid: int, coin: int, created: datetime) -> None: + coin_bal[uid] += coin + db.add(CoinTransaction( + user_id=uid, amount=coin, balance_after=coin_bal[uid], + biz_type="signin", ref_id=_bj_date(created), remark="每日签到", created_at=created, + )) + + u1 = users["13800000001"].id # 丰富:激励视频 + 信息流 + 签到 + 1 条签到膨胀 + for i, e in enumerate([3000, 5000, 8000, 4000, 6000, 3500, 7000, 4500, 5500, 6500]): + add_video(u1, e, ago(days=1, hours=i), nth=(i % 5) + 1) + add_video(u1, 9000, ago(hours=2), nth=1, scene="signin_boost") + # 信息流打 scene:比价×2 / 领券×1 / 未分类×1(演示金币记录「途径」分类) + feed_scenes = ["comparison", "coupon", "comparison", None] + for i, (e, units) in enumerate([(2000, 6), (3000, 4), (5000, 3), (2500, 5)]): + add_feed(u1, e, units, ago(days=2, hours=i), scene=feed_scenes[i]) + for i in range(5): + add_signin(u1, 2000, ago(days=i + 1)) + + u2 = users["13900000002"].id # 较轻:几条激励视频 + 签到 + for i, e in enumerate([3000, 4000, 5000]): + add_video(u2, e, ago(days=1, hours=i), nth=i + 1) + for i in range(3): + add_signin(u2, 2000, ago(days=i + 1)) + + db.commit() + return orders + + +def _yuan(cents: int) -> str: + return f"¥{cents / 100:.2f}" + + +def main() -> None: + parser = argparse.ArgumentParser(description="造提现 mock 数据(运营后台提现管理页联调用)") + parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建") + args = parser.parse_args() + + db = SessionLocal() + try: + removed = clean(db) + if removed: + print(f"🧹 已清理旧 mock:{removed} 个用户及其提现单/流水") + if args.clean_only: + print("✅ 仅清理,已完成。") + return + + orders = seed(db) + + # 汇总打印(对照页面应显示的内容) + cum: dict[int, int] = defaultdict(int) + for o in orders: + if o.status == "success": + cum[o.user_id] += o.amount_cents + uid2phone = dict(db.execute(select(User.id, User.phone).where(User.phone.in_(MOCK_PHONES))).all()) + + print(f"\n✅ 已生成 {len(orders)} 条提现单,分布:") + by_status: dict[str, list[WithdrawOrder]] = defaultdict(list) + for o in orders: + by_status[o.status].append(o) + label = {"reviewing": "待审核", "pending": "打款中", "success": "已到账", + "rejected": "已拒绝", "failed": "打款失败"} + for st in ("reviewing", "pending", "success", "rejected", "failed"): + lst = by_status.get(st, []) + total = sum(o.amount_cents for o in lst) + print(f" {label[st]:<5} {len(lst)} 单 合计 {_yuan(total)}") + + print("\n 明细(手机号 | 提现金额 | 累计提现 | 状态 | 失败原因):") + for o in sorted(orders, key=lambda x: (x.user_id, x.created_at)): + phone = uid2phone.get(o.user_id, "?") + print( + f" {phone} {_yuan(o.amount_cents):>8} 累计 {_yuan(cum.get(o.user_id, 0)):>8} " + f"{label[o.status]:<5} {o.fail_reason or ''}" + ) + print("\n👉 打开 http://localhost:3001 → 提现管理 查看。后端若没带 --reload 需重启才有新列。") + finally: + db.close() + + +if __name__ == "__main__": + main()