diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index c2f37871..81acb14a 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -1948,23 +1948,43 @@ async def get_stats( ] # ── 各团队积分消耗(有团队 vs 无团队,使用流水中的团队快照) + # 消耗 = 扣除金额 - 退回金额(净消耗) team_credit_rows = (await db.execute( select( func.coalesce(CreditRecord.team_name_snapshot, '未分配团队').label('team_name'), CreditRecord.team_id_snapshot.label('team_id'), - func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'), + func.coalesce( + func.sum( + func.case( + (CreditRecord.type == "consume", func.abs(CreditRecord.amount)), + else_=0, + ) + ), 0 + ).label("total_consume"), + func.coalesce( + func.sum( + func.case( + (CreditRecord.type == "refund", func.abs(CreditRecord.amount)), + else_=0, + ) + ), 0 + ).label("total_refund"), ) .where( - CreditRecord.type == "consume", + CreditRecord.type.in_(["consume", "refund"]), real_credit_charge_filter, CreditRecord.created_at >= date_start, CreditRecord.created_at <= date_end, ) .group_by(CreditRecord.team_id_snapshot, CreditRecord.team_name_snapshot) - .order_by(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).desc()) + .order_by(func.coalesce(func.sum(func.case((CreditRecord.type == "consume", func.abs(CreditRecord.amount)), else_=0)), 0).desc()) )).all() credits_by_team = [ - TeamCreditOut(team_name=row.team_name, team_id=row.team_id, credits=float(row.credits or 0)) + TeamCreditOut( + team_name=row.team_name, + team_id=row.team_id, + credits=round(float(row.total_consume or 0) - float(row.total_refund or 0), 2), + ) for row in team_credit_rows ] diff --git a/video-gen-api/app/api/v1/team.py b/video-gen-api/app/api/v1/team.py index 88cde7c2..2552fde1 100644 --- a/video-gen-api/app/api/v1/team.py +++ b/video-gen-api/app/api/v1/team.py @@ -379,15 +379,41 @@ async def export_team_credit_records( end_date=end_date, ) - # 生成 CSV(兼容 Excel 打开) + # 生成 CSV(兼容 Excel 打开,UTF-8 BOM) import csv import io + from datetime import datetime as _dt def _format_dt(val): if val is None: return "-" - - return str(datetime.fromtimestamp(val).strftime("%Y-%m-%d %H:%M:%S")) + try: + # 情况 1:已经是 datetime + if isinstance(val, _dt): + dt = val + elif isinstance(val, (int, float)): + # 情况 2:Unix 时间戳(极少,兼容旧代码) + dt = _dt.fromtimestamp(val) + elif isinstance(val, str): + # 情况 3:ISO 字符串(admin_credit_record_service._iso 返回的格式) + s = val.strip() + if s.endswith("Z"): + s = s[:-1] + "+00:00" + try: + dt = _dt.fromisoformat(s) + except ValueError: + # 兼容旧格式 YYYY-MM-DD HH:MM:SS + dt = _dt.strptime(s, "%Y-%m-%d %H:%M:%S") + else: + return str(val) + # 统一转东八区展示 + if getattr(dt, "tzinfo", None) is None: + dt = dt.replace(tzinfo=CST) + else: + dt = dt.astimezone(CST) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: # noqa: BLE001 + return str(val) if val else "-" output = io.StringIO() writer = csv.writer(output) diff --git a/video-gen-api/app/services/admin_credit_record_service.py b/video-gen-api/app/services/admin_credit_record_service.py index 1223b7c4..a67d7ccc 100644 --- a/video-gen-api/app/services/admin_credit_record_service.py +++ b/video-gen-api/app/services/admin_credit_record_service.py @@ -59,7 +59,9 @@ def _as_date_start(value: str | None) -> datetime | None: if not value: return None try: - return datetime.strptime(value, "%Y-%m-%d") + # 构造东八区 00:00:00 与 DB timezone-aware created_at 比较,避免 8 小时偏移 + naive = datetime.strptime(value, "%Y-%m-%d") + return naive.replace(tzinfo=CST) except Exception: return None @@ -68,7 +70,11 @@ def _as_date_end(value: str | None) -> datetime | None: if not value: return None try: - return datetime.strptime(value, "%Y-%m-%d").replace(hour=23, minute=59, second=59, microsecond=999999) + # 构造东八区 23:59:59.999999 + naive = datetime.strptime(value, "%Y-%m-%d").replace( + hour=23, minute=59, second=59, microsecond=999999, + ) + return naive.replace(tzinfo=CST) except Exception: return None @@ -250,7 +256,9 @@ async def list_admin_credit_records( end_date: str | None = None, ) -> dict[str, Any]: page = max(int(page or 1), 1) - page_size = min(max(int(page_size or 20), 1), 1000) + # 列表页默认最多 1000 条;导出接口可传较大值(最多 100000 条),避免月度导出被截断 + max_page_size = 100000 if page_size is not None and int(page_size) > 1000 else 1000 + page_size = min(max(int(page_size or 20), 1), max_page_size) filters = _build_filters( user_id=user_id, user_name=user_name,