185 lines
7.5 KiB
Python
185 lines
7.5 KiB
Python
from django.shortcuts import render
|
||
import logging
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.db.models import Sum
|
||
from django.shortcuts import get_object_or_404, redirect
|
||
from django.utils import timezone
|
||
from django.contrib import messages
|
||
|
||
from .models import Invoice
|
||
from domains.models import DomainTrafficDaily
|
||
from decimal import Decimal
|
||
from core.models import OperationLog
|
||
from core.models import SystemSettings
|
||
from django.http import HttpResponse
|
||
import hashlib
|
||
import urllib.parse
|
||
|
||
|
||
@login_required
|
||
def list_billing(request):
|
||
status = request.GET.get('status')
|
||
invoices_qs = Invoice.objects.filter(user=request.user)
|
||
if status:
|
||
invoices_qs = invoices_qs.filter(status=status)
|
||
invoices = invoices_qs.order_by('-period_end')
|
||
totals = invoices_qs.aggregate(total_amount=Sum('amount_total'))
|
||
return render(request, 'billing/list.html', {
|
||
'invoices': invoices,
|
||
'total_amount': totals.get('total_amount') or 0,
|
||
'status': status or '',
|
||
'status_choices': getattr(Invoice, 'STATUS_CHOICES', []),
|
||
})
|
||
|
||
|
||
@login_required
|
||
def invoice_detail(request, invoice_id: int):
|
||
invoice = get_object_or_404(Invoice, id=invoice_id)
|
||
# 权限:仅本人或staff可查看
|
||
if not (invoice.user_id == request.user.id or request.user.is_staff):
|
||
return redirect('billing:list')
|
||
|
||
if request.method == 'POST':
|
||
action = request.POST.get('action')
|
||
if action == 'mark_paid' and invoice.status == Invoice.STATUS_UNPAID:
|
||
invoice.status = Invoice.STATUS_PAID
|
||
invoice.paid_at = timezone.now()
|
||
invoice.save(update_fields=['status', 'paid_at'])
|
||
try:
|
||
OperationLog.objects.create(
|
||
actor=request.user,
|
||
action='invoice_mark_paid',
|
||
target=f"Invoice#{invoice.id}",
|
||
detail=f"period={invoice.period_start}→{invoice.period_end}; amount_total={invoice.amount_total}"
|
||
)
|
||
except Exception:
|
||
logger.exception('operation log write failed')
|
||
messages.success(request, '账单已标记为已支付。')
|
||
return redirect('billing:detail', invoice_id=invoice.id)
|
||
|
||
items = invoice.items.select_related('domain').all()
|
||
plan_total = invoice.amount_plan_total
|
||
overage_total = invoice.amount_overage_total
|
||
adjustment = invoice.amount_adjustment
|
||
amount_total = invoice.amount_total
|
||
# 聚合账单周期内的域名用量(GB)
|
||
traffic_by_domain = {}
|
||
domain_stats = []
|
||
period_start = invoice.period_start
|
||
period_end = invoice.period_end
|
||
for it in items:
|
||
if it.domain_id and it.domain_id not in traffic_by_domain:
|
||
bytes_sum = DomainTrafficDaily.objects.filter(domain_id=it.domain_id, day__gte=period_start, day__lte=period_end).aggregate(b=Sum('bytes'))['b'] or 0
|
||
gb = round(Decimal(str(bytes_sum)) / Decimal(str(1024 ** 3)), 3)
|
||
traffic_by_domain[it.domain_id] = gb
|
||
domain_stats.append({'domain': it.domain, 'gb': gb})
|
||
return render(request, 'billing/detail.html', {
|
||
'invoice': invoice,
|
||
'items': items,
|
||
'plan_total': plan_total,
|
||
'overage_total': overage_total,
|
||
'adjustment': adjustment,
|
||
'amount_total': amount_total,
|
||
'traffic_by_domain': traffic_by_domain,
|
||
'domain_stats': domain_stats,
|
||
'period_start': period_start,
|
||
'period_end': period_end,
|
||
})
|
||
|
||
|
||
@login_required
|
||
def invoice_pay(request, invoice_id: int):
|
||
invoice = get_object_or_404(Invoice, id=invoice_id, user=request.user)
|
||
if invoice.status != Invoice.STATUS_UNPAID:
|
||
return redirect('billing:detail', invoice_id=invoice.id)
|
||
settings_obj = SystemSettings.objects.order_by('id').first()
|
||
api = (settings_obj.epay_api_base_url or '').rstrip('/')
|
||
pid = settings_obj.epay_pid or ''
|
||
key = settings_obj.epay_key or ''
|
||
if not (api and pid and key):
|
||
messages.error(request, '支付未配置')
|
||
return redirect('billing:detail', invoice_id=invoice.id)
|
||
pay_type = request.GET.get('type') or 'alipay'
|
||
params = {
|
||
'pid': pid,
|
||
'type': pay_type,
|
||
'out_trade_no': f'INV{invoice.id}',
|
||
'notify_url': request.build_absolute_uri(reverse('billing:notify')),
|
||
'return_url': request.build_absolute_uri(reverse('billing:return')),
|
||
'name': f'Invoice#{invoice.id}',
|
||
'money': str(invoice.amount_total),
|
||
'sitename': 'PyGoEdge',
|
||
'sign_type': 'MD5',
|
||
}
|
||
sign_src = '&'.join(f"{k}={params[k]}" for k in sorted(params) if k not in ['sign', 'sign_type'] and params[k] != '') + '&key=' + key
|
||
sign = hashlib.md5(sign_src.encode('utf-8')).hexdigest().upper()
|
||
params['sign'] = sign
|
||
url = api + '/submit.php?' + urllib.parse.urlencode(params)
|
||
try:
|
||
OperationLog.objects.create(actor=request.user, action='invoice_pay_create', target=f'Invoice#{invoice.id}', detail=url)
|
||
except Exception:
|
||
pass
|
||
return redirect(url)
|
||
|
||
|
||
def _verify_epay_sign(params: dict, key: str) -> bool:
|
||
p = {k: v for k, v in params.items() if k not in ['sign', 'sign_type'] and v is not None and v != ''}
|
||
src = '&'.join(f"{k}={p[k]}" for k in sorted(p)) + '&key=' + key
|
||
calc = hashlib.md5(src.encode('utf-8')).hexdigest().upper()
|
||
return calc == (params.get('sign') or '').upper()
|
||
|
||
|
||
def payment_notify(request):
|
||
settings_obj = SystemSettings.objects.order_by('id').first()
|
||
key = settings_obj.epay_key or ''
|
||
params = dict(request.GET.items())
|
||
ok = (_verify_epay_sign(params, key) if key else False)
|
||
out_trade_no = params.get('out_trade_no') or ''
|
||
trade_status = params.get('trade_status') or ''
|
||
money = params.get('money') or ''
|
||
if ok and out_trade_no.startswith('INV') and trade_status:
|
||
inv_id = int(out_trade_no.replace('INV', ''))
|
||
inv = Invoice.objects.filter(id=inv_id).first()
|
||
if inv and inv.status == Invoice.STATUS_UNPAID:
|
||
if str(inv.amount_total) == str(money):
|
||
inv.status = Invoice.STATUS_PAID
|
||
inv.paid_at = timezone.now()
|
||
inv.save(update_fields=['status', 'paid_at'])
|
||
try:
|
||
OperationLog.objects.create(actor=None, action='invoice_paid_notify', target=f'Invoice#{inv.id}', detail=out_trade_no)
|
||
except Exception:
|
||
pass
|
||
return HttpResponse('SUCCESS')
|
||
return HttpResponse('FAIL')
|
||
|
||
|
||
@login_required
|
||
def payment_return(request):
|
||
messages.info(request, '支付流程已完成,如账单仍显示未支付,请稍后或刷新页面。')
|
||
return redirect('billing:list')
|
||
|
||
|
||
@login_required
|
||
def invoice_detail_csv(request, invoice_id: int):
|
||
invoice = get_object_or_404(Invoice, id=invoice_id)
|
||
if not (invoice.user_id == request.user.id or request.user.is_staff):
|
||
return redirect('billing:list')
|
||
import csv
|
||
from django.http import HttpResponse
|
||
response = HttpResponse(content_type='text/csv; charset=utf-8')
|
||
response['Content-Disposition'] = f'attachment; filename="invoice_{invoice.id}.csv"'
|
||
writer = csv.writer(response)
|
||
writer.writerow(['Domain', 'Description', 'Quantity(GB)', 'Unit Price', 'Amount'])
|
||
for it in invoice.items.select_related('domain').all():
|
||
writer.writerow([
|
||
it.domain.name if it.domain else '',
|
||
it.description,
|
||
str(it.quantity),
|
||
str(it.unit_price),
|
||
str(it.amount),
|
||
])
|
||
return response
|
||
|
||
# Create your views here.
|
||
logger = logging.getLogger(__name__)
|