merge main
This commit is contained in:
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, func, or_, select, update
|
||||
from sqlalchemy import and_, case, delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_admin_user
|
||||
@@ -63,6 +63,7 @@ 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
|
||||
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
@@ -1721,17 +1722,62 @@ async def update_system_config(
|
||||
return config
|
||||
|
||||
|
||||
@router.post("/system-configs/banner/reset", summary="重置活动横幅展示")
|
||||
async def reset_banner(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""递增 site_banner_version,使所有用户再次看到横幅。"""
|
||||
from app.utils.id_gen import generate_id
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.key == "site_banner_version").limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
new_version = 1
|
||||
if config:
|
||||
try:
|
||||
new_version = int(config.value or 0) + 1
|
||||
except ValueError:
|
||||
new_version = 1
|
||||
config.value = str(new_version)
|
||||
else:
|
||||
config = SystemConfig(
|
||||
id=generate_id(),
|
||||
key="site_banner_version",
|
||||
value=str(new_version),
|
||||
description="活动横幅版本号,递增后所有用户重新看到横幅",
|
||||
)
|
||||
db.add(config)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"重置活动横幅 (版本 → {new_version})",
|
||||
"POST",
|
||||
"/admin/system-configs/banner/reset",
|
||||
detail=json.dumps({"new_version": new_version}),
|
||||
)
|
||||
await db.commit()
|
||||
await invalidate_system_config_cache(["site_banner_version"])
|
||||
return {"site_banner_version": new_version}
|
||||
|
||||
|
||||
# ── Operation Logs ──────────────────────────────────────
|
||||
|
||||
@router.get("/operation-logs")
|
||||
async def list_operation_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=500),
|
||||
action: str | None = Query(None, description="按 action 过滤(前缀匹配)"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(OperationLog).order_by(OperationLog.created_at.desc())
|
||||
count_query = select(func.count(OperationLog.id))
|
||||
|
||||
if action:
|
||||
query = query.where(OperationLog.action.like(f"{action}%"))
|
||||
count_query = count_query.where(OperationLog.action.like(f"{action}%"))
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
@@ -1774,17 +1820,26 @@ async def get_stats(
|
||||
):
|
||||
today_start = datetime.now(CST).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
date_start: datetime
|
||||
date_end: datetime
|
||||
try:
|
||||
if start_date:
|
||||
date_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST)
|
||||
else:
|
||||
date_start = today_start
|
||||
if end_date:
|
||||
date_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=CST)
|
||||
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
# 先构造完整的 naive 日期时刻,再一次性 attach tzinfo(避免分步 replace 丢 tzinfo)
|
||||
naive_end = datetime.strptime(end_date, "%Y-%m-%d").replace(
|
||||
hour=23, minute=59, second=59, microsecond=999999,
|
||||
)
|
||||
date_end = naive_end.replace(tzinfo=CST)
|
||||
else:
|
||||
date_end = datetime.now(CST)
|
||||
except:
|
||||
# 合法性:end >= start
|
||||
if date_end < date_start:
|
||||
date_end = date_start.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
except (ValueError, TypeError):
|
||||
# 只拦截日期解析错误,不吞掉 SQL/运行时异常(原裸 except 会吞所有错误导致用户看不到报错)
|
||||
date_start = today_start
|
||||
date_end = datetime.now(CST)
|
||||
|
||||
@@ -1827,20 +1882,45 @@ async def get_stats(
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# 预扣占用不是实际消费;历史流水 charge_action 为空时仍按真实扣费兼容。
|
||||
# 消费类(真实扣费 + 预扣占用):charge_action 为空时仍按真实扣费兼容;hold 为预扣占用。
|
||||
credit_charge_action_filter = or_(
|
||||
CreditRecord.charge_action.is_(None),
|
||||
CreditRecord.charge_action == "charge",
|
||||
CreditRecord.charge_action == "hold",
|
||||
)
|
||||
# 「仅真实扣费」filter 用于图表、模型使用次数等需要按实际产出(非预扣)统计的场景。
|
||||
real_credit_charge_filter = or_(
|
||||
CreditRecord.charge_action.is_(None),
|
||||
CreditRecord.charge_action == "charge",
|
||||
)
|
||||
|
||||
credits_consumed = (await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
||||
CreditRecord.type == "consume",
|
||||
real_credit_charge_filter,
|
||||
# 核心数据「消耗积分」= 净消耗 = 真实消费 + 预扣占用 - 真实退款 - 预扣释放。
|
||||
# 说明:
|
||||
# hold(预扣占用):type=consume,charge_action='hold',amount<0
|
||||
# hold_release(预扣释放退回):type=refund,charge_action='hold_release',amount>0
|
||||
# (账本 L256 强校验:hold_release.type 必须是 'refund',不是 consume)
|
||||
# charge(真实扣费):type=consume,charge_action='charge' 或 NULL(历史),amount<0
|
||||
# refund(真实退款):type=refund,charge_action='refund' 或 NULL(历史兼容),amount>0
|
||||
# 因此 type=refund 天然包含「真实退款 + 预扣释放退回」两类子流水。
|
||||
_stats_real_and_hold = case(
|
||||
(and_(CreditRecord.type == "consume", credit_charge_action_filter), func.abs(CreditRecord.amount)),
|
||||
else_=0,
|
||||
)
|
||||
_stats_refund_and_release = case(
|
||||
(CreditRecord.type == "refund", func.abs(CreditRecord.amount)),
|
||||
else_=0,
|
||||
)
|
||||
_net_row = (await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(_stats_real_and_hold), 0),
|
||||
func.coalesce(func.sum(_stats_refund_and_release), 0),
|
||||
).where(
|
||||
CreditRecord.type.in_(["consume", "refund"]),
|
||||
CreditRecord.created_at >= date_start,
|
||||
CreditRecord.created_at <= date_end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
)).one()
|
||||
credits_consumed = round(max(float(_net_row[0] or 0) - float(_net_row[1] or 0), 0.0), 2)
|
||||
|
||||
alipay_revenue = (await db.execute(
|
||||
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
|
||||
@@ -1904,21 +1984,31 @@ async def get_stats(
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
last_period_credits_consumed = (await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
||||
CreditRecord.type == "consume",
|
||||
real_credit_charge_filter,
|
||||
last_period_net_row = (await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(_stats_real_and_hold), 0),
|
||||
func.coalesce(func.sum(_stats_refund_and_release), 0),
|
||||
).where(
|
||||
CreditRecord.type.in_(["consume", "refund"]),
|
||||
CreditRecord.created_at >= last_period_start,
|
||||
CreditRecord.created_at <= last_period_end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
)).one()
|
||||
last_period_credits_consumed = round(
|
||||
max(float(last_period_net_row[0] or 0) - float(last_period_net_row[1] or 0), 0.0), 2,
|
||||
)
|
||||
|
||||
# ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示)
|
||||
# created_at 为 timestamptz,数据库 session 时区已是东八区(CST),
|
||||
# 读取出来的时间值即为北京时间,直接 CAST 成日期即可,无需再 +8 小时。
|
||||
from sqlalchemy import Date, cast as sa_cast
|
||||
_day_expr = sa_cast(CreditRecord.created_at, Date)
|
||||
# 图表固定展示 [date_end - 6天, date_end] 共7天
|
||||
_chart_end_dt = date_end
|
||||
_chart_start_dt = _chart_end_dt - timedelta(days=6)
|
||||
_chart_start_dt = datetime(
|
||||
_chart_end_dt.year, _chart_end_dt.month, _chart_end_dt.day, 0, 0, 0, 0, tzinfo=CST,
|
||||
) - timedelta(days=6)
|
||||
_chart_end_dt_inclusive = _chart_end_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
_inner = (
|
||||
select(
|
||||
_day_expr.label('date'),
|
||||
@@ -1929,7 +2019,7 @@ async def get_stats(
|
||||
CreditRecord.type == "consume",
|
||||
real_credit_charge_filter,
|
||||
CreditRecord.created_at >= _chart_start_dt,
|
||||
CreditRecord.created_at <= _chart_end_dt,
|
||||
CreditRecord.created_at <= _chart_end_dt_inclusive,
|
||||
)
|
||||
.group_by(_day_expr, CreditRecord.source_module)
|
||||
.subquery()
|
||||
@@ -1973,23 +2063,41 @@ async def get_stats(
|
||||
]
|
||||
|
||||
# ── 各团队积分消耗(有团队 vs 无团队,使用流水中的团队快照)
|
||||
# 净消耗 = (真实消费 charge + 预扣占用 hold) - (真实退款 refund + 预扣释放 hold_release)
|
||||
# 注意:
|
||||
# hold(预扣占用):type=consume,charge_action='hold',amount<0 → 加项
|
||||
# hold_release(预扣释放):type=refund,charge_action='hold_release',amount>0 → 减项(type=refund 天然包含)
|
||||
# charge(真实扣费):type=consume,charge/NULL → 加项
|
||||
# refund(真实退款):type=refund,refund/NULL → 减项
|
||||
_charge_hold_filter = and_(
|
||||
CreditRecord.type == "consume",
|
||||
credit_charge_action_filter, # charge / hold / NULL(历史 charge)
|
||||
)
|
||||
_charge_hold_expr = case((_charge_hold_filter, func.abs(CreditRecord.amount)), else_=0)
|
||||
# type=refund = 真实退款 + 预扣释放退回(账本强制 hold_release.type=refund)
|
||||
_refund_release_expr = case((CreditRecord.type == "refund", func.abs(CreditRecord.amount)), else_=0)
|
||||
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(_charge_hold_expr), 0).label("total_charge_hold"),
|
||||
func.coalesce(func.sum(_refund_release_expr), 0).label("total_refund_release"),
|
||||
)
|
||||
.where(
|
||||
CreditRecord.type == "consume",
|
||||
real_credit_charge_filter,
|
||||
CreditRecord.type.in_(["consume", "refund"]),
|
||||
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(_charge_hold_expr), 0) - func.coalesce(func.sum(_refund_release_expr), 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(max(float(row.total_charge_hold or 0) - float(row.total_refund_release or 0), 0.0), 2),
|
||||
)
|
||||
for row in team_credit_rows
|
||||
]
|
||||
|
||||
@@ -2127,6 +2235,8 @@ async def admin_list_generation_records(
|
||||
status: str | None = Query(None),
|
||||
engine_id: str | None = Query(None),
|
||||
include_media_references: bool | None = Query(None),
|
||||
start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"),
|
||||
end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=500),
|
||||
admin: User = Depends(get_admin_user),
|
||||
@@ -2149,6 +2259,10 @@ async def admin_list_generation_records(
|
||||
query = query.where(GenerationRecord.engine_id == engine_id)
|
||||
if include_media_references is not None:
|
||||
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||
if start_date:
|
||||
query = query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
|
||||
if end_date:
|
||||
query = query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
|
||||
|
||||
# Count total
|
||||
count_query = (
|
||||
@@ -2164,6 +2278,10 @@ async def admin_list_generation_records(
|
||||
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
|
||||
if include_media_references is not None:
|
||||
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||
if start_date:
|
||||
count_query = count_query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
|
||||
if end_date:
|
||||
count_query = count_query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
@@ -2404,6 +2522,118 @@ async def upload_login_video(
|
||||
return {"url": url}
|
||||
|
||||
|
||||
# ── Payment Stats ────────────────────────────────────────
|
||||
# ── Invoice Management ───────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/invoices")
|
||||
async def admin_list_invoices(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=500),
|
||||
status: str | None = Query(None),
|
||||
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
|
||||
start_date: str | None = Query(None),
|
||||
end_date: str | None = Query(None),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""后台发票列表(分页+筛选)。"""
|
||||
from app.services.invoice import get_admin_invoices
|
||||
|
||||
items, total = await get_admin_invoices(
|
||||
db, page, page_size,
|
||||
status_filter=status,
|
||||
phone=phone,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}")
|
||||
async def admin_get_invoice(
|
||||
invoice_id: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""后台获取发票详情(含关联订单)。"""
|
||||
from app.services.invoice import get_invoice_with_orders
|
||||
|
||||
detail = await get_invoice_with_orders(db, invoice_id)
|
||||
if not detail:
|
||||
raise HTTPException(status_code=404, detail="发票不存在")
|
||||
|
||||
invoice = detail["invoice"]
|
||||
orders = detail["orders"]
|
||||
return {
|
||||
"id": invoice.id,
|
||||
"invoiceNo": invoice.invoice_no,
|
||||
"userId": invoice.user_id,
|
||||
"headerType": invoice.header_type,
|
||||
"headerName": invoice.header_name,
|
||||
"headerTaxNo": invoice.header_tax_no,
|
||||
"headerRegisterAddress": invoice.header_register_address,
|
||||
"headerRegisterPhone": invoice.header_register_phone,
|
||||
"headerBankName": invoice.header_bank_name,
|
||||
"headerBankAccount": invoice.header_bank_account,
|
||||
"email": invoice.email,
|
||||
"totalAmount": round(float(invoice.total_amount), 2),
|
||||
"totalCredits": round(float(invoice.total_credits), 2),
|
||||
"status": invoice.status,
|
||||
"failureReason": invoice.failure_reason,
|
||||
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
|
||||
"createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
|
||||
"updatedAt": invoice.updated_at.isoformat() if invoice.updated_at else None,
|
||||
"orders": [
|
||||
{
|
||||
"id": o.id,
|
||||
"orderNo": o.order_no,
|
||||
"amount": round(float(o.amount), 2),
|
||||
"credits": round(float(o.credits), 2),
|
||||
}
|
||||
for o in orders
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.put("/invoices/{invoice_id}/status")
|
||||
async def admin_update_invoice_status(
|
||||
invoice_id: str,
|
||||
req: InvoiceStatusUpdateRequest,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新发票状态(success/failed)。"""
|
||||
from app.services.invoice import update_invoice_status
|
||||
|
||||
invoice, old_status = await update_invoice_status(db, invoice_id, req, admin.id)
|
||||
await db.flush()
|
||||
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"发票状态变更: {invoice.invoice_no} {old_status} → {req.status}",
|
||||
"PUT",
|
||||
f"/admin/invoices/{invoice_id}/status",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"invoice_id": invoice_id,
|
||||
"invoice_no": invoice.invoice_no,
|
||||
"old_status": old_status,
|
||||
"new_status": req.status,
|
||||
"failure_reason": req.failure_reason,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"id": invoice.id,
|
||||
"invoiceNo": invoice.invoice_no,
|
||||
"status": invoice.status,
|
||||
"failureReason": invoice.failure_reason,
|
||||
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user