feat(user): 昵称上限放宽到 20 字(与客户端/原型一致) (#63)

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #63
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
This commit was merged in pull request #63.
This commit is contained in:
2026-06-21 00:16:40 +08:00
committed by marco
parent f7a3ef2e0b
commit 8a2f72d366
24 changed files with 1310 additions and 18 deletions
+34
View File
@@ -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")
@@ -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
+2
View File
@@ -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.dashboard import router as dashboard_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_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 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.onboarding import router as onboarding_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_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 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(withdraw_router)
admin_app.include_router(price_report_router) admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router) admin_app.include_router(feedback_router)
admin_app.include_router(feedback_qr_router)
admin_app.include_router(admins_router) admin_app.include_router(admins_router)
admin_app.include_router(audit_router) admin_app.include_router(audit_router)
admin_app.include_router(config_router) admin_app.include_router(config_router)
+215
View File
@@ -11,6 +11,9 @@ from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, desc, func, or_, select from sqlalchemy import Select, asc, desc, func, or_, select
from sqlalchemy.orm import Session 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.admin import AdminAuditLog
from app.models.comparison import ComparisonRecord from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback 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.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder 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( def cursor_paginate(
db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None 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) 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( def list_feedbacks(
db: Session, 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( def list_price_reports(
db: Session, db: Session,
*, *,
+92
View File
@@ -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)
+42
View File
@@ -17,6 +17,8 @@ from app.admin.schemas.user import (
GrantCoinsRequest, GrantCoinsRequest,
SetDebugTraceRequest, SetDebugTraceRequest,
SetUserStatusRequest, SetUserStatusRequest,
UserCoinRecord,
UserRewardStats,
) )
from app.models.admin import AdminUser from app.models.admin import AdminUser
from app.repositories import user as user_repo 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) 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="封禁/解封用户") @router.post("/{user_id}/status", response_model=OkResponse, summary="封禁/解封用户")
def set_user_status( def set_user_status(
user_id: int, user_id: int,
+13 -7
View File
@@ -25,6 +25,7 @@ from app.admin.schemas.wallet import (
WithdrawBulkItemResult, WithdrawBulkItemResult,
WithdrawDetailOut, WithdrawDetailOut,
WithdrawLedgerCheckOut, WithdrawLedgerCheckOut,
WithdrawListItemOut,
WithdrawOrderOut, WithdrawOrderOut,
WithdrawRejectRequest, WithdrawRejectRequest,
WithdrawSummaryOut, WithdrawSummaryOut,
@@ -44,7 +45,7 @@ router = APIRouter(
) )
@router.get("", response_model=CursorPage[WithdrawOrderOut], summary="提现单列表") @router.get("", response_model=CursorPage[WithdrawListItemOut], summary="提现单列表")
def list_withdraws( def list_withdraws(
db: AdminDb, db: AdminDb,
user_id: Annotated[int | None, Query()] = None, user_id: Annotated[int | None, Query()] = None,
@@ -63,7 +64,7 @@ def list_withdraws(
] = None, ] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20, limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None, cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[WithdrawOrderOut]: ) -> CursorPage[WithdrawListItemOut]:
items, next_cursor, total = queries.list_all_withdraw_orders( items, next_cursor, total = queries.list_all_withdraw_orders(
db, db,
user_id=user_id, user_id=user_id,
@@ -78,11 +79,16 @@ def list_withdraws(
limit=limit, limit=limit,
cursor=cursor, cursor=cursor,
) )
return CursorPage( # 联表带出手机号/昵称 + 累计成功提现(本页 user_id 批量富化,2 条聚合查询,无 N+1)。
items=[WithdrawOrderOut.model_validate(o) for o in items], enrichment = queries.withdraw_list_enrichment(db, [o.user_id for o in items])
next_cursor=next_cursor, _empty = {"phone": None, "nickname": None, "cumulative_success_cents": 0}
total=total, 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="提现审核台统计") @router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
+22
View File
@@ -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)
+30
View File
@@ -37,6 +37,36 @@ class AdminUserOverview(BaseModel):
feedback_total: int 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: def _strip_reason(v: str) -> str:
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因 # min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因
if not v.strip(): if not v.strip():
+11
View File
@@ -50,6 +50,17 @@ class WithdrawOrderOut(BaseModel):
updated_at: datetime 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): class WithdrawSummaryOut(BaseModel):
reviewing_count: int reviewing_count: int
reviewing_amount_cents: int reviewing_amount_cents: int
+1
View File
@@ -372,6 +372,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
ad_session_id=payload.ad_session_id, ad_session_id=payload.ad_session_id,
adn=payload.adn, adn=payload.adn,
slot_id=payload.slot_id, slot_id=payload.slot_id,
feed_scene=payload.feed_scene,
app_env=payload.app_env, app_env=payload.app_env,
our_code_id=payload.our_code_id, our_code_id=payload.our_code_id,
aborted=payload.aborted, aborted=payload.aborted,
+8 -1
View File
@@ -14,7 +14,8 @@ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from app.api.deps import CurrentUser, DbSession from app.api.deps import CurrentUser, DbSession
from app.core import media from app.core import media
from app.repositories import feedback as feedback_repo 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") 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)) logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
return FeedbackOut.model_validate(fb) 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))
+7 -3
View File
@@ -129,10 +129,14 @@ class Settings(BaseSettings):
WITHDRAW_AUTO_RECONCILE_ENABLED: bool = False WITHDRAW_AUTO_RECONCILE_ENABLED: bool = False
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC: int = 300 WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC: int = 300
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES: int = 15 WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES: int = 15
# 0 点自动兑金币(deploy/daily-exchange.timer 触发 scripts.daily_auto_exchange)。 # 0 点自动兑金币:App 进程内任务 app.core.daily_exchange_worker 跨 0 点跑一轮
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停可在 .env 置 false, # wallet.daily_auto_exchange(原 deploy/daily-exchange.timer 已不再需要,见 deploy/daily-exchange.md)。
# 无需动 systemd timer——脚本读此开关,false 时直接 no-op 退出。 # 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停在 .env 置 false
# (worker 不启动;脚本也 no-op)。
AUTO_EXCHANGE_ENABLED: bool = True AUTO_EXCHANGE_ENABLED: bool = True
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。 # 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容 # 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。 # (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
+125
View File
@@ -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
+26 -6
View File
@@ -37,7 +37,7 @@ def _sniff_ext(data: bytes) -> str | None:
return 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。""" """通用图片落盘:校验非空 / ≤上限 / 魔数为图片,随机文件名,返回相对 URL。"""
if not data: if not data:
raise MediaError("空文件") raise MediaError("空文件")
@@ -47,11 +47,16 @@ def _save_image(subdir: str, user_id: int, data: bytes) -> str:
if ext is None: if ext is None:
raise MediaError("仅支持 JPEG / PNG / WebP 图片") 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) (_media_dir(subdir) / fname).write_bytes(data)
return f"{settings.MEDIA_URL_PREFIX}/{subdir}/{fname}" 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: def save_avatar(user_id: int, data: bytes) -> str:
"""保存头像,返回相对 URL(`/media/avatars/<file>`)。""" """保存头像,返回相对 URL(`/media/avatars/<file>`)。"""
return _save_image("avatars", user_id, data) 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) return _save_image("price_report", user_id, data)
def save_feedback_qr(data: bytes) -> str:
"""保存反馈页二维码(运营后台上传的运营素材,非用户文件),返回相对 URL(`/media/feedback_qr/<file>`)。"""
return _save_named("feedback_qr", "qr", data)
def save_cps_image(admin_id: int, data: bytes) -> str: def save_cps_image(admin_id: int, data: bytes) -> str:
"""保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。admin_id 入文件名便于追溯。""" """保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。admin_id 入文件名便于追溯。"""
return _save_image("cps", admin_id, data) 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 return f"{base}{rel_url}" if base else rel_url
def delete_avatar(url: str | None) -> None: def _delete_managed(subdir: str, url: str | None) -> None:
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。""" """删除本服务托管的某子目录下文件;外部 URL / 空值 / 非本子目录的不处理。"""
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/" prefix = f"{settings.MEDIA_URL_PREFIX}/{subdir}/"
if not url or not url.startswith(prefix): if not url or not url.startswith(prefix):
return return
fname = url[len(prefix):] fname = url[len(prefix):]
if not fname or "/" in fname or "\\" in fname or ".." in fname: if not fname or "/" in fname or "\\" in fname or ".." in fname:
return # 防路径穿越 return # 防路径穿越
try: try:
(_media_dir("avatars") / fname).unlink(missing_ok=True) (_media_dir(subdir) / fname).unlink(missing_ok=True)
except OSError: except OSError:
pass 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)
+6
View File
@@ -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.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings 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.logging import setup_logging
from app.core.withdraw_reconcile_worker import ( from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker, start_withdraw_reconcile_worker,
@@ -59,10 +63,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.DATABASE_URL.split("://", 1)[0], settings.DATABASE_URL.split("://", 1)[0],
) )
reconcile_task = start_withdraw_reconcile_worker() reconcile_task = start_withdraw_reconcile_worker()
daily_exchange_task = start_daily_exchange_worker()
try: try:
yield yield
finally: finally:
await stop_withdraw_reconcile_worker(reconcile_task) await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
logger.info("shutting down") logger.info("shutting down")
+3
View File
@@ -30,6 +30,9 @@ class AdFeedRewardRecord(Base):
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True) adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), 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。 # 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True) app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True) our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+6
View File
@@ -69,6 +69,7 @@ def grant_feed_reward(
ad_session_id: str | None = None, ad_session_id: str | None = None,
adn: str | None = None, adn: str | None = None,
slot_id: str | None = None, slot_id: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None, app_env: str | None = None,
our_code_id: str | None = None, our_code_id: str | None = None,
aborted: bool = False, aborted: bool = False,
@@ -82,6 +83,7 @@ def grant_feed_reward(
duration_seconds 是整场累计秒数一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 duration_seconds 是整场累计秒数一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到
FEED_MAX_DURATION_SECONDS 限单场份数,eCPM rewards.calculate_ad_reward_coin 内钳到 FEED_MAX_DURATION_SECONDS 限单场份数,eCPM rewards.calculate_ad_reward_coin 内钳到
AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间 AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间
feed_scene:点位场景(comparison/coupon/welfare),仅做归类落库,不参与发奖计算
""" """
existing = _find_by_event(db, client_event_id) existing = _find_by_event(db, client_event_id)
if existing is not None: if existing is not None:
@@ -104,6 +106,7 @@ def grant_feed_reward(
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env, app_env=app_env,
our_code_id=our_code_id, our_code_id=our_code_id,
coin=0, coin=0,
@@ -122,6 +125,7 @@ def grant_feed_reward(
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env, app_env=app_env,
our_code_id=our_code_id, our_code_id=our_code_id,
coin=0, coin=0,
@@ -141,6 +145,7 @@ def grant_feed_reward(
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env, app_env=app_env,
our_code_id=our_code_id, our_code_id=our_code_id,
coin=0, coin=0,
@@ -165,6 +170,7 @@ def grant_feed_reward(
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env, app_env=app_env,
our_code_id=our_code_id, our_code_id=our_code_id,
coin=coin, coin=coin,
+106
View File
@@ -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
+6
View File
@@ -135,6 +135,12 @@ class FeedRewardIn(BaseModel):
duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)") duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)")
adn: str | None = Field(None, description="实际投放 ADN") adn: str | None = Field(None, description="实际投放 ADN")
slot_id: str | None = Field(None, description="实际展示代码位") 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( app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
) )
+14
View File
@@ -12,3 +12,17 @@ class FeedbackOut(BaseModel):
id: int id: int
status: str status: str
created_at: datetime 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
+11 -1
View File
@@ -3,6 +3,15 @@
> 对象:维护「每天 0 点把用户金币自动兑成现金」这套定时任务的同事。 > 对象:维护「每天 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 点跑一轮: 客户端已删手动「兑金币」入口、改为「0 点自动兑现金(可能存在延迟)」,故服务端每天 0 点跑一轮:
把每个用户的金币按汇率 **100 金币 = 1 分** 兑成现金。 把每个用户的金币按汇率 **100 金币 = 1 分** 兑成现金。
@@ -21,8 +30,9 @@
| systemd 单元 | `deploy/daily-exchange.{service,timer}` | | systemd 单元 | `deploy/daily-exchange.{service,timer}` |
| 运行锁 | `/tmp/daily_auto_exchange.lock`(可用环境变量 `DAILY_EXCHANGE_LOCK` 覆盖) | | 运行锁 | `/tmp/daily_auto_exchange.lock`(可用环境变量 `DAILY_EXCHANGE_LOCK` 覆盖) |
## 部署(Linux 服务器) ## (遗留 / 可选)systemd timer 部署 — 现已被进程内 worker 取代,正常无需执行
```bash ```bash
# ⚠️ 仅在你明确想用外部 timer 而非进程内 worker 时才做(并把 worker 关掉),否则跳过。
sudo cp deploy/daily-exchange.{service,timer} /etc/systemd/system/ sudo cp deploy/daily-exchange.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now daily-exchange.timer sudo systemctl daemon-reload && sudo systemctl enable --now daily-exchange.timer
systemctl list-timers daily-exchange.timer # 确认下次触发时间 systemctl list-timers daily-exchange.timer # 确认下次触发时间
+208
View File
@@ -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/<name>)。"""
_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()
+296
View File
@@ -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()