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:
@@ -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,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user