Files
video-gen/video-gen-api/app/services/upload_resource/accounting_service.py
T

263 lines
10 KiB
Python

from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.upload_resource import UploadResourceTypeEnum
from app.models.generated_resource import GeneratedResource
from app.models.upload_resource import UploadResource
from app.models.user_resource_month_stat import UserResourceMonthStat
from app.models.user_resource_total_stat import UserResourceTotalStat
from app.utils.id_gen import generate_id
def resource_month_from_datetime(value: datetime | None = None) -> date:
value = value or datetime.now(timezone.utc)
return date(value.year, value.month, 1)
def _int(value: Any) -> int:
return int(value or 0)
def _add_non_negative(obj: Any, field: str, delta: int) -> None:
current = _int(getattr(obj, field, 0))
setattr(obj, field, max(0, current + int(delta or 0)))
def _add_raw(obj: Any, field: str, delta: int) -> None:
current = _int(getattr(obj, field, 0))
setattr(obj, field, current + int(delta or 0))
async def get_or_create_month_stat(db: AsyncSession, user_id: str, stat_month: date) -> UserResourceMonthStat:
result = await db.execute(
select(UserResourceMonthStat).where(
UserResourceMonthStat.user_id == user_id,
UserResourceMonthStat.stat_month == stat_month,
).limit(1)
)
stat = result.scalar_one_or_none()
if stat:
return stat
stat = UserResourceMonthStat(id=generate_id(), user_id=user_id, stat_month=stat_month)
db.add(stat)
await db.flush()
return stat
async def get_or_create_total_stat(db: AsyncSession, user_id: str, *, for_update: bool = False) -> UserResourceTotalStat:
stmt = select(UserResourceTotalStat).where(UserResourceTotalStat.user_id == user_id).limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
stat = result.scalar_one_or_none()
if stat:
return stat
stat = UserResourceTotalStat(id=generate_id(), user_id=user_id)
db.add(stat)
await db.flush()
if for_update:
result = await db.execute(
select(UserResourceTotalStat)
.where(UserResourceTotalStat.user_id == user_id)
.with_for_update()
.limit(1)
)
locked = result.scalar_one_or_none()
if locked:
return locked
return stat
async def apply_upload_resource_stat_delta(
db: AsyncSession,
*,
user_id: str,
stat_month: date,
resource_type: str,
active_size_delta: int = 0,
active_count_delta: int = 0,
deleted_size_delta: int = 0,
deleted_count_delta: int = 0,
upload_size_delta: int = 0,
upload_count_delta: int = 0,
) -> None:
month_stat = await get_or_create_month_stat(db, user_id, stat_month)
total_stat = await get_or_create_total_stat(db, user_id)
now = datetime.now(timezone.utc)
for stat in (month_stat, total_stat):
_add_non_negative(stat, "active_size_bytes", active_size_delta)
_add_non_negative(stat, "active_count", active_count_delta)
_add_non_negative(stat, "deleted_size_bytes", deleted_size_delta)
_add_non_negative(stat, "deleted_count", deleted_count_delta)
_add_raw(stat, "upload_size_bytes", upload_size_delta)
_add_raw(stat, "upload_count", upload_count_delta)
if resource_type == UploadResourceTypeEnum.IMAGE.value:
_add_non_negative(stat, "image_size_bytes", active_size_delta)
_add_non_negative(stat, "image_count", active_count_delta)
elif resource_type == UploadResourceTypeEnum.VIDEO.value:
_add_non_negative(stat, "video_size_bytes", active_size_delta)
_add_non_negative(stat, "video_count", active_count_delta)
elif resource_type == UploadResourceTypeEnum.AUDIO.value:
_add_non_negative(stat, "audio_size_bytes", active_size_delta)
_add_non_negative(stat, "audio_count", active_count_delta)
elif resource_type == UploadResourceTypeEnum.SHOT_SEGMENT.value:
_add_non_negative(stat, "shot_segment_size_bytes", active_size_delta)
_add_non_negative(stat, "shot_segment_count", active_count_delta)
stat.last_recalculated_at = now
async def release_upload_resource_capacity(db: AsyncSession, resource: UploadResource, *, released_at: datetime | None = None) -> bool:
if resource.capacity_released_at is not None:
return False
released_at = released_at or datetime.now(timezone.utc)
stat_month = resource_month_from_datetime(resource.created_at or released_at)
size = _int(resource.file_size_bytes)
resource.capacity_released_at = released_at
await apply_upload_resource_stat_delta(
db,
user_id=resource.user_id,
stat_month=stat_month,
resource_type=resource.resource_type,
active_size_delta=-size,
active_count_delta=-1,
deleted_size_delta=size,
deleted_count_delta=1,
)
return True
async def rebuild_user_resource_stats(db: AsyncSession, *, user_ids: list[str] | None = None) -> dict[str, int]:
"""按数据库真实资源账本重算统计。
说明:这里只重置并重算 user_ids 范围内的统计。未传 user_ids 时重算所有在资源表中出现过的用户。
"""
if user_ids is None:
ids: set[str] = set()
for model in (GeneratedResource, UploadResource):
result = await db.execute(select(model.user_id).distinct())
ids.update(v for v in result.scalars().all() if v)
user_ids = sorted(ids)
else:
user_ids = sorted({v for v in user_ids if v})
if not user_ids:
return {"users": 0, "month_rows": 0, "total_rows": 0}
await db.execute(update(UserResourceTotalStat).where(UserResourceTotalStat.user_id.in_(user_ids)).values(
active_size_bytes=0,
deleted_size_bytes=0,
total_generated_size_bytes=0,
upload_size_bytes=0,
image_size_bytes=0,
video_size_bytes=0,
audio_size_bytes=0,
shot_segment_size_bytes=0,
active_count=0,
deleted_count=0,
upload_count=0,
image_count=0,
video_count=0,
audio_count=0,
shot_segment_count=0,
last_recalculated_at=datetime.now(timezone.utc),
))
await db.execute(update(UserResourceMonthStat).where(UserResourceMonthStat.user_id.in_(user_ids)).values(
active_size_bytes=0,
deleted_size_bytes=0,
total_generated_size_bytes=0,
upload_size_bytes=0,
image_size_bytes=0,
video_size_bytes=0,
audio_size_bytes=0,
shot_segment_size_bytes=0,
active_count=0,
deleted_count=0,
upload_count=0,
image_count=0,
video_count=0,
audio_count=0,
shot_segment_count=0,
last_recalculated_at=datetime.now(timezone.utc),
))
month_rows = 0
total_rows = 0
gen_rows = await db.execute(
select(
GeneratedResource.user_id,
GeneratedResource.resource_month,
GeneratedResource.resource_type,
GeneratedResource.deleted_at,
func.count(GeneratedResource.id),
func.coalesce(func.sum(GeneratedResource.file_size_bytes), 0),
).where(GeneratedResource.user_id.in_(user_ids)).group_by(
GeneratedResource.user_id,
GeneratedResource.resource_month,
GeneratedResource.resource_type,
GeneratedResource.deleted_at,
)
)
for user_id, month, rtype, deleted_at, count, size in gen_rows.all():
active = deleted_at is None
await apply_upload_resource_stat_delta(
db,
user_id=user_id,
stat_month=month,
resource_type=rtype,
active_size_delta=int(size or 0) if active else 0,
active_count_delta=int(count or 0) if active else 0,
deleted_size_delta=0 if active else int(size or 0),
deleted_count_delta=0 if active else int(count or 0),
)
# 生成资源字段单独累加
month_stat = await get_or_create_month_stat(db, user_id, month)
total_stat = await get_or_create_total_stat(db, user_id)
if active:
_add_raw(month_stat, "total_generated_size_bytes", int(size or 0))
_add_raw(total_stat, "total_generated_size_bytes", int(size or 0))
upload_rows = await db.execute(
select(
UploadResource.user_id,
func.date_trunc("month", UploadResource.created_at).label("month"),
UploadResource.resource_type,
UploadResource.deleted_at,
func.count(UploadResource.id),
func.coalesce(func.sum(UploadResource.file_size_bytes), 0),
).where(UploadResource.user_id.in_(user_ids)).group_by(
UploadResource.user_id,
"month",
UploadResource.resource_type,
UploadResource.deleted_at,
)
)
for user_id, month_dt, rtype, deleted_at, count, size in upload_rows.all():
month = resource_month_from_datetime(month_dt or datetime.now(timezone.utc))
active = deleted_at is None
await apply_upload_resource_stat_delta(
db,
user_id=user_id,
stat_month=month,
resource_type=rtype,
active_size_delta=int(size or 0) if active else 0,
active_count_delta=int(count or 0) if active else 0,
deleted_size_delta=0 if active else int(size or 0),
deleted_count_delta=0 if active else int(count or 0),
upload_size_delta=int(size or 0) if active else 0,
upload_count_delta=int(count or 0) if active else 0,
)
await db.flush()
total_rows = len(user_ids)
month_count = await db.execute(select(func.count(UserResourceMonthStat.id)).where(UserResourceMonthStat.user_id.in_(user_ids)))
month_rows = int(month_count.scalar_one() or 0)
return {"users": len(user_ids), "month_rows": month_rows, "total_rows": total_rows}