团队积分V1
This commit is contained in:
+174
-134
@@ -24,6 +24,8 @@ from app.models.credit_ratio import CreditRatio
|
||||
from app.models.operation_log import OperationLog
|
||||
from app.enums.user import FrontendUserKind, UserType
|
||||
from app.enums.team import TEAM_UNASSIGNED_VALUE
|
||||
from app.enums.common import PAYMENT_ORDER_SOURCE_LABELS
|
||||
from app.schemas.payment import PAYMENT_METHOD_LABELS, PAYMENT_STATUS_LABELS, FULFILLMENT_STATUS_LABELS
|
||||
from app.schemas.admin import (
|
||||
CreditAdjustRequest,
|
||||
ModelConfigCreate,
|
||||
@@ -49,7 +51,7 @@ from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
|
||||
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
|
||||
from app.services.credits import add_credits, deduct_credits
|
||||
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
|
||||
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits, get_user_credit_map
|
||||
from app.services.credit.query_service import attach_credit_snapshot, get_balance_summary, get_user_credit_summary_map
|
||||
from app.services.credit.time_policy import add_natural_months
|
||||
from app.services.credit.utils import utc_now
|
||||
from app.services.credit_record_meta_service import build_admin_adjust_meta
|
||||
@@ -60,7 +62,6 @@ from app.services.auth import hash_password, verify_password
|
||||
from app.services.operation_log import log_operation
|
||||
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.services.payment import process_refund
|
||||
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
|
||||
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
|
||||
from app.schemas.invoice import InvoiceStatusUpdateRequest
|
||||
@@ -134,9 +135,10 @@ async def list_users(
|
||||
team_ids = [getattr(u, "team_id", None) for u in users if getattr(u, "team_id", None)]
|
||||
capacity_map = await batch_get_user_resource_capacity_usage(db, user_ids)
|
||||
team_name_map = await batch_get_team_name_map(db, team_ids)
|
||||
credit_map = await get_user_credit_map(db, user_ids)
|
||||
credit_summary_map = await get_user_credit_summary_map(db, user_ids)
|
||||
for item in users:
|
||||
attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
|
||||
summary = credit_summary_map.get(item.id)
|
||||
attach_credit_snapshot(item, summary.available_credits if summary else 0)
|
||||
return {
|
||||
"items": [
|
||||
AdminUserOut.model_validate(user)
|
||||
@@ -144,6 +146,9 @@ async def list_users(
|
||||
update={
|
||||
"resource_capacity": capacity_map.get(user.id),
|
||||
"team_name": team_name_map.get(getattr(user, "team_id", None)),
|
||||
"personal_credits": float(credit_summary_map[user.id].personal_credits) if user.id in credit_summary_map else 0.0,
|
||||
"team_available_credits": float(credit_summary_map[user.id].team_available_credits) if user.id in credit_summary_map else 0.0,
|
||||
"team_frozen_credits": float(credit_summary_map[user.id].team_frozen_credits) if user.id in credit_summary_map else 0.0,
|
||||
}
|
||||
)
|
||||
.model_dump(mode="json")
|
||||
@@ -277,13 +282,17 @@ async def get_user(
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
attach_credit_snapshot(user, await get_available_credits(db, user.id))
|
||||
credit_summary = await get_balance_summary(db, user.id)
|
||||
attach_credit_snapshot(user, credit_summary.available_credits)
|
||||
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
|
||||
team_name_map = await batch_get_team_name_map(db, [getattr(user, "team_id", None)])
|
||||
return AdminUserOut.model_validate(user).model_copy(
|
||||
update={
|
||||
"resource_capacity": resource_capacity,
|
||||
"team_name": team_name_map.get(getattr(user, "team_id", None)),
|
||||
"personal_credits": float(credit_summary.personal_credits),
|
||||
"team_available_credits": float(credit_summary.team_available_credits),
|
||||
"team_frozen_credits": float(credit_summary.team_frozen_credits),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -308,7 +317,14 @@ async def adjust_credits(
|
||||
biz_key=f"admin-adjust-credit:{admin.id}:{generate_id()}",
|
||||
)
|
||||
else:
|
||||
await deduct_credits(db, user_id, abs(req.amount), f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
|
||||
await deduct_credits(
|
||||
db,
|
||||
user_id,
|
||||
abs(req.amount),
|
||||
f"管理员调整: {req.description}",
|
||||
record_meta=build_admin_adjust_meta(),
|
||||
allowed_scopes={"personal"},
|
||||
)
|
||||
await create_notification(
|
||||
db, user_id, "积分变动通知",
|
||||
f"您的积分已{'增加' if req.amount > 0 else '扣除'}{abs(req.amount)}积分。原因:{req.description}",
|
||||
@@ -568,6 +584,7 @@ async def list_credit_records(
|
||||
user_type: str | None = Query(None),
|
||||
frontend_user_kind: str | None = Query(None),
|
||||
team_id: str | None = Query(None),
|
||||
subscription_no: str | None = Query(None),
|
||||
record_type: str | None = Query(None),
|
||||
type: str | None = Query(None),
|
||||
credit_subject: str | None = Query(None),
|
||||
@@ -592,6 +609,7 @@ async def list_credit_records(
|
||||
user_type=user_type,
|
||||
frontend_user_kind=frontend_user_kind,
|
||||
team_id=team_id,
|
||||
subscription_no=subscription_no,
|
||||
record_type=record_type or type,
|
||||
credit_subject=credit_subject,
|
||||
media_type=media_type,
|
||||
@@ -806,107 +824,112 @@ async def batch_update_payment_configs(
|
||||
@router.get("/payment-stats")
|
||||
async def get_payment_stats(
|
||||
payment_method: str | None = Query(None),
|
||||
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
|
||||
status: str | None = Query(None),
|
||||
start_date: str | None = Query(None),
|
||||
end_date: str | None = Query(None),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return payment statistics for admin dashboard with filters."""
|
||||
from sqlalchemy import func
|
||||
|
||||
# Ensure by_status has all expected statuses with defaults
|
||||
"""支付统计:线上、后台线下和总真实收入可分别统计。"""
|
||||
del admin
|
||||
by_status = {
|
||||
"pending": {"count": 0, "amount": 0.0},
|
||||
"paid": {"count": 0, "amount": 0.0},
|
||||
"cancelled": {"count": 0, "amount": 0.0},
|
||||
"refunded": {"count": 0, "amount": 0.0},
|
||||
"pending": {"label": "待支付", "count": 0, "amount": 0.0},
|
||||
"paid": {"label": "已支付", "count": 0, "amount": 0.0},
|
||||
"cancelled": {"label": "已取消", "count": 0, "amount": 0.0},
|
||||
"expired": {"label": "已过期", "count": 0, "amount": 0.0},
|
||||
"failed": {"label": "失败", "count": 0, "amount": 0.0},
|
||||
"refunded": {"label": "已退款", "count": 0, "amount": 0.0},
|
||||
}
|
||||
|
||||
# Parse dates and build base query filters
|
||||
now_cst = datetime.now(CST)
|
||||
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST) if start_date else today_start
|
||||
query_end = (
|
||||
(datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
|
||||
if end_date else today_end
|
||||
)
|
||||
|
||||
# Default to today if no date range provided
|
||||
query_start = today_start
|
||||
query_end = today_end
|
||||
|
||||
if start_date:
|
||||
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST)
|
||||
if end_date:
|
||||
query_end = (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
|
||||
|
||||
# Build filter list for status breakdown
|
||||
breakdown_filters = []
|
||||
filters = [PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
|
||||
if payment_method:
|
||||
breakdown_filters.append(PaymentOrder.payment_method == payment_method)
|
||||
filters.append(PaymentOrder.payment_method == payment_method)
|
||||
if order_source:
|
||||
filters.append(PaymentOrder.order_source == order_source)
|
||||
if status:
|
||||
breakdown_filters.append(PaymentOrder.status == status)
|
||||
# Always apply date range to breakdown
|
||||
breakdown_filters.append(PaymentOrder.created_at >= query_start)
|
||||
breakdown_filters.append(PaymentOrder.created_at < query_end)
|
||||
filters.append(PaymentOrder.status == status)
|
||||
|
||||
# Status breakdown
|
||||
status_result = await db.execute(
|
||||
select(
|
||||
PaymentOrder.status,
|
||||
func.count().label("count"),
|
||||
func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"),
|
||||
)
|
||||
.where(*breakdown_filters)
|
||||
select(PaymentOrder.status, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
|
||||
.where(*filters)
|
||||
.group_by(PaymentOrder.status)
|
||||
)
|
||||
for row in status_result.all():
|
||||
if row.status in by_status:
|
||||
if row.status not in by_status:
|
||||
by_status[row.status] = {
|
||||
"count": row.count,
|
||||
"amount": round(float(row.amount), 2)
|
||||
"label": PAYMENT_STATUS_LABELS.get(row.status, "其他状态"),
|
||||
"count": 0,
|
||||
"amount": 0.0,
|
||||
}
|
||||
else:
|
||||
# Map any unexpected status to cancelled
|
||||
by_status["cancelled"]["count"] += row.count
|
||||
by_status["cancelled"]["amount"] += round(float(row.amount), 2)
|
||||
by_status[row.status]["count"] = int(row.count or 0)
|
||||
by_status[row.status]["amount"] = round(float(row.amount or 0), 2)
|
||||
|
||||
# Today's stats (CST time zone) - independent of filter
|
||||
today_result = await db.execute(
|
||||
select(
|
||||
func.count().label("paid_count"),
|
||||
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
|
||||
).where(
|
||||
PaymentOrder.status == "paid",
|
||||
PaymentOrder.paid_at >= today_start,
|
||||
PaymentOrder.paid_at < today_end,
|
||||
)
|
||||
source_filters = [PaymentOrder.status == "paid", PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
|
||||
if payment_method:
|
||||
source_filters.append(PaymentOrder.payment_method == payment_method)
|
||||
if order_source:
|
||||
source_filters.append(PaymentOrder.order_source == order_source)
|
||||
source_result = await db.execute(
|
||||
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
|
||||
.where(*source_filters)
|
||||
.group_by(PaymentOrder.order_source)
|
||||
)
|
||||
today_row = today_result.one()
|
||||
by_source = {
|
||||
"online_payment": {"label": "线上支付", "count": 0, "amount": 0.0},
|
||||
"admin_offline": {"label": "后台线下成交", "count": 0, "amount": 0.0},
|
||||
}
|
||||
for row in source_result.all():
|
||||
target = by_source.setdefault(
|
||||
row.order_source,
|
||||
{"label": PAYMENT_ORDER_SOURCE_LABELS.get(row.order_source, "其他订单来源"), "count": 0, "amount": 0.0},
|
||||
)
|
||||
target["count"] = int(row.count or 0)
|
||||
target["amount"] = round(float(row.amount or 0), 2)
|
||||
total_income = {
|
||||
"label": "总真实收入",
|
||||
"count": sum(int(item["count"]) for item in by_source.values()),
|
||||
"amount": round(sum(float(item["amount"]) for item in by_source.values()), 2),
|
||||
}
|
||||
|
||||
async def _period_income(start_at: datetime, end_at: datetime) -> dict:
|
||||
result = await db.execute(
|
||||
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
|
||||
.where(
|
||||
PaymentOrder.status == "paid",
|
||||
PaymentOrder.paid_at >= start_at,
|
||||
PaymentOrder.paid_at < end_at,
|
||||
)
|
||||
.group_by(PaymentOrder.order_source)
|
||||
)
|
||||
source_map = {row.order_source: (int(row.count or 0), round(float(row.amount or 0), 2)) for row in result.all()}
|
||||
online_count, online_amount = source_map.get("online_payment", (0, 0.0))
|
||||
offline_count, offline_amount = source_map.get("admin_offline", (0, 0.0))
|
||||
return {
|
||||
"paid_count": online_count + offline_count,
|
||||
"paid_amount": round(online_amount + offline_amount, 2),
|
||||
"online_paid_count": online_count,
|
||||
"online_paid_amount": online_amount,
|
||||
"offline_paid_count": offline_count,
|
||||
"offline_paid_amount": offline_amount,
|
||||
}
|
||||
|
||||
# Monthly cumulative stats
|
||||
month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
month_result = await db.execute(
|
||||
select(
|
||||
func.count().label("paid_count"),
|
||||
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
|
||||
).where(
|
||||
PaymentOrder.status == "paid",
|
||||
PaymentOrder.paid_at >= month_start,
|
||||
PaymentOrder.paid_at < month_end,
|
||||
)
|
||||
)
|
||||
month_row = month_result.one()
|
||||
|
||||
return {
|
||||
"by_status": by_status,
|
||||
"today": {
|
||||
"paid_count": today_row.paid_count,
|
||||
"paid_amount": round(float(today_row.paid_amount), 2),
|
||||
},
|
||||
"month": {
|
||||
"paid_count": month_row.paid_count,
|
||||
"paid_amount": round(float(month_row.paid_amount), 2),
|
||||
},
|
||||
"by_source": by_source,
|
||||
"total_income": total_income,
|
||||
"today": await _period_income(today_start, today_end),
|
||||
"month": await _period_income(month_start, month_end),
|
||||
}
|
||||
|
||||
|
||||
@@ -915,6 +938,7 @@ async def list_payment_orders(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=500),
|
||||
payment_method: str | None = Query(None),
|
||||
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
|
||||
status: str | None = Query(None),
|
||||
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
|
||||
start_date: str | None = Query(None),
|
||||
@@ -922,13 +946,15 @@ async def list_payment_orders(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return paginated payment orders for admin dashboard."""
|
||||
"""管理后台统一订单列表;线上和后台线下成交均来自 payment_orders。"""
|
||||
del admin
|
||||
query = select(PaymentOrder, User.username, User.phone).join(User, PaymentOrder.user_id == User.id)
|
||||
count_query = select(func.count(PaymentOrder.id))
|
||||
|
||||
count_query = select(func.count(PaymentOrder.id)).join(User, PaymentOrder.user_id == User.id)
|
||||
filters = []
|
||||
if payment_method:
|
||||
filters.append(PaymentOrder.payment_method == payment_method)
|
||||
if order_source:
|
||||
filters.append(PaymentOrder.order_source == order_source)
|
||||
if status:
|
||||
filters.append(PaymentOrder.status == status)
|
||||
if phone:
|
||||
@@ -937,43 +963,68 @@ async def list_payment_orders(
|
||||
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
|
||||
if end_date:
|
||||
filters.append(PaymentOrder.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST))
|
||||
if filters:
|
||||
query = query.where(*filters)
|
||||
count_query = count_query.where(*filters)
|
||||
|
||||
for f in filters:
|
||||
query = query.where(f)
|
||||
count_query = count_query.where(f)
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
result = await db.execute(
|
||||
query.order_by(PaymentOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": o.id,
|
||||
"orderNo": o.order_no,
|
||||
"order_no": o.order_no,
|
||||
"userId": o.user_id,
|
||||
"user_id": o.user_id,
|
||||
"username": username,
|
||||
"phone": user_phone,
|
||||
"amount": round(float(o.amount), 2),
|
||||
"credits": round(float(o.credits), 2),
|
||||
"paymentMethod": o.payment_method,
|
||||
"payment_method": o.payment_method,
|
||||
"status": o.status,
|
||||
"tradeNo": o.trade_no,
|
||||
"trade_no": o.trade_no,
|
||||
"paidAt": o.paid_at.isoformat() if o.paid_at else None,
|
||||
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
|
||||
"createdAt": o.created_at.isoformat() if o.created_at else None,
|
||||
"created_at": o.created_at.isoformat() if o.created_at else None,
|
||||
}
|
||||
for o, username, user_phone in rows
|
||||
]
|
||||
total = int((await db.execute(count_query)).scalar() or 0)
|
||||
rows = (await db.execute(
|
||||
query.order_by(PaymentOrder.created_at.desc(), PaymentOrder.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)).all()
|
||||
|
||||
items = []
|
||||
for order, username, user_phone in rows:
|
||||
payment_label = PAYMENT_METHOD_LABELS.get(order.payment_method, "其他支付方式")
|
||||
if order.payment_method == "other" and order.offline_payment_detail:
|
||||
payment_label = f"其他-线下收款({order.offline_payment_detail})"
|
||||
items.append(
|
||||
{
|
||||
"id": order.id,
|
||||
"orderNo": order.order_no,
|
||||
"order_no": order.order_no,
|
||||
"userId": order.user_id,
|
||||
"user_id": order.user_id,
|
||||
"username": username,
|
||||
"phone": user_phone,
|
||||
"amount": round(float(order.amount), 2),
|
||||
"credits": round(float(order.credits), 2),
|
||||
"quantity": int(order.quantity or 1),
|
||||
"quoted_unit_price_snapshot": float(order.quoted_unit_price_snapshot) if order.quoted_unit_price_snapshot is not None else None,
|
||||
"quoted_amount_snapshot": float(order.quoted_amount_snapshot) if order.quoted_amount_snapshot is not None else None,
|
||||
"actual_unit_price_snapshot": float(order.actual_unit_price_snapshot) if order.actual_unit_price_snapshot is not None else None,
|
||||
"paymentMethod": order.payment_method,
|
||||
"payment_method": order.payment_method,
|
||||
"payment_method_label": payment_label,
|
||||
"order_source": order.order_source,
|
||||
"order_source_label": PAYMENT_ORDER_SOURCE_LABELS.get(order.order_source, "其他订单来源"),
|
||||
"status": order.status,
|
||||
"status_label": PAYMENT_STATUS_LABELS.get(order.status, "其他状态"),
|
||||
"product_id": order.product_id,
|
||||
"product_type": order.product_type,
|
||||
"product_name_snapshot": order.product_name_snapshot,
|
||||
"team_id_snapshot": order.team_id_snapshot,
|
||||
"fulfillment_status": order.fulfillment_status,
|
||||
"fulfillment_status_label": FULFILLMENT_STATUS_LABELS.get(order.fulfillment_status, "其他履约状态") if order.fulfillment_status else None,
|
||||
"tradeNo": order.trade_no,
|
||||
"trade_no": order.trade_no,
|
||||
"offline_trade_no": order.offline_trade_no,
|
||||
"offline_payment_detail": order.offline_payment_detail,
|
||||
"remark": order.remark,
|
||||
"refund_amount": float(order.refund_amount) if order.refund_amount is not None else None,
|
||||
"refund_trade_no": order.refund_trade_no,
|
||||
"refund_entitlement_status": order.refund_entitlement_status,
|
||||
"paidAt": order.paid_at.isoformat() if order.paid_at else None,
|
||||
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
|
||||
"refunded_at": order.refunded_at.isoformat() if order.refunded_at else None,
|
||||
"createdAt": order.created_at.isoformat() if order.created_at else None,
|
||||
"created_at": order.created_at.isoformat() if order.created_at else None,
|
||||
}
|
||||
)
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
@router.put("/payment-configs/{config_id}")
|
||||
async def update_payment_config(
|
||||
config_id: str,
|
||||
@@ -1024,25 +1075,14 @@ async def refund_payment_order(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Refund a paid payment order."""
|
||||
result = await process_refund(db, order_no)
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=result.get("message", "退款失败"))
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"订单退款: {order_no}",
|
||||
"POST",
|
||||
f"/admin/payment-orders/{order_no}/refund",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"order_no": order_no,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return result
|
||||
"""保留退款 API 路由兼容旧客户端,但本版本明确不开放主动订单退款。"""
|
||||
del admin
|
||||
exists = (await db.execute(
|
||||
select(PaymentOrder.id).where(PaymentOrder.order_no == order_no).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if not exists:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
raise HTTPException(status_code=409, detail="当前版本暂未开放订单退款")
|
||||
|
||||
|
||||
# ── Industry Config ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user