762 lines
32 KiB
Python
762 lines
32 KiB
Python
|
|
from django.shortcuts import render, redirect
|
|||
|
|
import json
|
|||
|
|
import logging
|
|||
|
|
from django.contrib.auth.decorators import login_required
|
|||
|
|
from django.http import HttpResponseForbidden
|
|||
|
|
from django.contrib import messages
|
|||
|
|
from django.db import models
|
|||
|
|
from django.db.models import Count, Sum
|
|||
|
|
from django.views.decorators.http import require_POST
|
|||
|
|
from django.contrib.auth import get_user_model
|
|||
|
|
from django.db import transaction
|
|||
|
|
from django.urls import reverse
|
|||
|
|
|
|||
|
|
from core.models import SystemSettings
|
|||
|
|
from plans.models import Plan
|
|||
|
|
from domains.models import Domain
|
|||
|
|
from billing.models import Invoice
|
|||
|
|
from .forms import SystemSettingsForm, PlanForm, DomainPlanSwitchForm, DomainGrantTrafficForm, InvoiceAdjustmentForm
|
|||
|
|
from billing.models import Invoice, InvoiceItem
|
|||
|
|
from decimal import Decimal
|
|||
|
|
from django.utils import timezone
|
|||
|
|
from core.goedge_client import GoEdgeClient
|
|||
|
|
from core.models import OperationLog
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _staff_only(request):
|
|||
|
|
if not request.user.is_authenticated:
|
|||
|
|
return False
|
|||
|
|
return request.user.is_staff
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
def dashboard(request):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
users_count = request.user.__class__.objects.count()
|
|||
|
|
domains_count = Domain.objects.count()
|
|||
|
|
plans_count = Plan.objects.count()
|
|||
|
|
unpaid_invoices = Invoice.objects.filter(status='unpaid').count() if hasattr(Invoice, 'status') else 0
|
|||
|
|
return render(request, 'admin_panel/dashboard.html', {
|
|||
|
|
'users_count': users_count,
|
|||
|
|
'domains_count': domains_count,
|
|||
|
|
'plans_count': plans_count,
|
|||
|
|
'unpaid_invoices': unpaid_invoices,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
def operation_logs(request):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
q_actor = request.GET.get('actor', '').strip()
|
|||
|
|
q_action = request.GET.get('action', '').strip()
|
|||
|
|
q_target = request.GET.get('target', '').strip()
|
|||
|
|
start = request.GET.get('start') or ''
|
|||
|
|
end = request.GET.get('end') or ''
|
|||
|
|
export = request.GET.get('export') or ''
|
|||
|
|
qs = OperationLog.objects.select_related('actor').all()
|
|||
|
|
if q_actor:
|
|||
|
|
qs = qs.filter(actor__username__icontains=q_actor)
|
|||
|
|
if q_action:
|
|||
|
|
qs = qs.filter(action__icontains=q_action)
|
|||
|
|
if q_target:
|
|||
|
|
qs = qs.filter(target__icontains=q_target)
|
|||
|
|
if start:
|
|||
|
|
try:
|
|||
|
|
from django.utils.dateparse import parse_datetime, parse_date
|
|||
|
|
dt = parse_datetime(start) or parse_date(start)
|
|||
|
|
if dt:
|
|||
|
|
qs = qs.filter(created_at__gte=dt)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
if end:
|
|||
|
|
try:
|
|||
|
|
from django.utils.dateparse import parse_datetime, parse_date
|
|||
|
|
dt = parse_datetime(end) or parse_date(end)
|
|||
|
|
if dt:
|
|||
|
|
qs = qs.filter(created_at__lte=dt)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
qs = qs.order_by('-created_at')
|
|||
|
|
if export == 'csv':
|
|||
|
|
import csv
|
|||
|
|
from django.http import HttpResponse
|
|||
|
|
resp = HttpResponse(content_type='text/csv; charset=utf-8')
|
|||
|
|
resp['Content-Disposition'] = 'attachment; filename="operation_logs.csv"'
|
|||
|
|
writer = csv.writer(resp)
|
|||
|
|
writer.writerow(['时间', '操作人', '动作', '目标', '详情'])
|
|||
|
|
for row in qs[:5000]:
|
|||
|
|
writer.writerow([
|
|||
|
|
row.created_at, (row.actor.username if row.actor else ''), row.action, row.target, row.detail,
|
|||
|
|
])
|
|||
|
|
return resp
|
|||
|
|
from django.core.paginator import Paginator
|
|||
|
|
paginator = Paginator(qs, 50)
|
|||
|
|
page = request.GET.get('page') or 1
|
|||
|
|
try:
|
|||
|
|
page_obj = paginator.get_page(page)
|
|||
|
|
except Exception:
|
|||
|
|
page_obj = paginator.get_page(1)
|
|||
|
|
return render(request, 'admin_panel/operation_logs.html', {
|
|||
|
|
'page_obj': page_obj,
|
|||
|
|
'actor': q_actor,
|
|||
|
|
'action': q_action,
|
|||
|
|
'target': q_target,
|
|||
|
|
'start': start,
|
|||
|
|
'end': end,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
def settings(request):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
|
|||
|
|
settings_obj = SystemSettings.objects.order_by('id').first()
|
|||
|
|
if not settings_obj:
|
|||
|
|
settings_obj = SystemSettings.objects.create()
|
|||
|
|
|
|||
|
|
if request.method == 'POST':
|
|||
|
|
action = request.POST.get('action') or 'save'
|
|||
|
|
if action == 'save':
|
|||
|
|
form = SystemSettingsForm(request.POST, instance=settings_obj)
|
|||
|
|
if form.is_valid():
|
|||
|
|
form.save()
|
|||
|
|
messages.success(request, '系统设置已保存')
|
|||
|
|
return redirect('admin_panel:settings')
|
|||
|
|
else:
|
|||
|
|
messages.error(request, '保存失败,请检查表单字段')
|
|||
|
|
elif action == 'get_token':
|
|||
|
|
form = SystemSettingsForm(instance=settings_obj)
|
|||
|
|
try:
|
|||
|
|
client = GoEdgeClient()
|
|||
|
|
token = client._ensure_token() # 触发获取/续期
|
|||
|
|
messages.success(request, f'已获取 AccessToken(已缓存):{token[:8]}...')
|
|||
|
|
except Exception as e:
|
|||
|
|
messages.error(request, f'获取 AccessToken 失败:{e}')
|
|||
|
|
elif action == 'test_connection':
|
|||
|
|
form = SystemSettingsForm(instance=settings_obj)
|
|||
|
|
try:
|
|||
|
|
client = GoEdgeClient() # 构造时即验证 base_url
|
|||
|
|
_ = client._headers() # 触发令牌确保
|
|||
|
|
messages.success(request, '与 GoEdge API 连接正常(令牌有效)')
|
|||
|
|
except Exception as e:
|
|||
|
|
messages.error(request, f'连接测试失败:{e}')
|
|||
|
|
elif action == 'validate_access_log_policy':
|
|||
|
|
form = SystemSettingsForm(request.POST, instance=settings_obj)
|
|||
|
|
if form.is_valid():
|
|||
|
|
pid = form.cleaned_data.get('default_http_access_log_policy_id')
|
|||
|
|
if not pid:
|
|||
|
|
messages.error(request, '请先填写访问日志策略ID')
|
|||
|
|
else:
|
|||
|
|
try:
|
|||
|
|
client = GoEdgeClient()
|
|||
|
|
exists = client.check_access_log_policy_exists(int(pid))
|
|||
|
|
messages.success(request, f'访问日志策略ID {pid} 校验结果:{"存在" if exists else "不存在"}')
|
|||
|
|
# 操作日志
|
|||
|
|
try:
|
|||
|
|
OperationLog.objects.create(
|
|||
|
|
actor=request.user,
|
|||
|
|
action='validate_policy_id',
|
|||
|
|
target='SystemSettings',
|
|||
|
|
detail=json.dumps({'type': 'access_log', 'policyId': int(pid), 'exists': bool(exists)}, ensure_ascii=False)
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
logger.exception('operation log write failed')
|
|||
|
|
except Exception as e:
|
|||
|
|
messages.error(request, f'访问日志策略校验失败:{e}')
|
|||
|
|
else:
|
|||
|
|
messages.error(request, '策略ID格式不正确,请检查输入')
|
|||
|
|
elif action == 'validate_firewall_policy':
|
|||
|
|
form = SystemSettingsForm(request.POST, instance=settings_obj)
|
|||
|
|
if form.is_valid():
|
|||
|
|
pid = form.cleaned_data.get('default_http_firewall_policy_id')
|
|||
|
|
if not pid:
|
|||
|
|
messages.error(request, '请先填写WAF策略ID')
|
|||
|
|
else:
|
|||
|
|
try:
|
|||
|
|
client = GoEdgeClient()
|
|||
|
|
exists = client.check_firewall_policy_exists(int(pid))
|
|||
|
|
messages.success(request, f'WAF策略ID {pid} 校验结果:{"存在" if exists else "不存在"}')
|
|||
|
|
# 操作日志
|
|||
|
|
try:
|
|||
|
|
OperationLog.objects.create(
|
|||
|
|
actor=request.user,
|
|||
|
|
action='validate_policy_id',
|
|||
|
|
target='SystemSettings',
|
|||
|
|
detail=json.dumps({'type': 'firewall', 'policyId': int(pid), 'exists': bool(exists)}, ensure_ascii=False)
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
logger.exception('operation log write failed')
|
|||
|
|
except Exception as e:
|
|||
|
|
messages.error(request, f'WAF策略校验失败:{e}')
|
|||
|
|
else:
|
|||
|
|
messages.error(request, '策略ID格式不正确,请检查输入')
|
|||
|
|
else:
|
|||
|
|
form = SystemSettingsForm(instance=settings_obj)
|
|||
|
|
messages.error(request, '未知操作')
|
|||
|
|
else:
|
|||
|
|
form = SystemSettingsForm(instance=settings_obj)
|
|||
|
|
|
|||
|
|
return render(request, 'admin_panel/settings.html', {
|
|||
|
|
'form': form,
|
|||
|
|
'settings_obj': settings_obj,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
def plans_list(request):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
plans = Plan.objects.all().order_by('-is_active', 'base_price_per_domain')
|
|||
|
|
return render(request, 'admin_panel/plans_list.html', {
|
|||
|
|
'plans': plans,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
def plan_create(request):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
if request.method == 'POST':
|
|||
|
|
form = PlanForm(request.POST)
|
|||
|
|
if form.is_valid():
|
|||
|
|
plan = form.save()
|
|||
|
|
messages.success(request, f'套餐已创建:{plan.name}')
|
|||
|
|
return redirect('admin_panel:plans')
|
|||
|
|
else:
|
|||
|
|
messages.error(request, '创建失败,请检查表单字段')
|
|||
|
|
else:
|
|||
|
|
form = PlanForm(initial={'billing_mode': 'per_domain_monthly'})
|
|||
|
|
return render(request, 'admin_panel/plan_form.html', {
|
|||
|
|
'form': form,
|
|||
|
|
'is_edit': False,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
def plan_edit(request, plan_id: int):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
plan = Plan.objects.filter(id=plan_id).first()
|
|||
|
|
if not plan:
|
|||
|
|
messages.error(request, '套餐不存在')
|
|||
|
|
return redirect('admin_panel:plans')
|
|||
|
|
if request.method == 'POST':
|
|||
|
|
form = PlanForm(request.POST, instance=plan)
|
|||
|
|
if form.is_valid():
|
|||
|
|
form.save()
|
|||
|
|
messages.success(request, f'套餐已更新:{plan.name}')
|
|||
|
|
return redirect('admin_panel:plans')
|
|||
|
|
else:
|
|||
|
|
messages.error(request, '更新失败,请检查表单字段')
|
|||
|
|
else:
|
|||
|
|
form = PlanForm(instance=plan)
|
|||
|
|
return render(request, 'admin_panel/plan_form.html', {
|
|||
|
|
'form': form,
|
|||
|
|
'is_edit': True,
|
|||
|
|
'plan': plan,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
@require_POST
|
|||
|
|
def plan_toggle_active(request, plan_id: int):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
plan = Plan.objects.filter(id=plan_id).first()
|
|||
|
|
if not plan:
|
|||
|
|
messages.error(request, '套餐不存在')
|
|||
|
|
return redirect('admin_panel:plans')
|
|||
|
|
plan.is_active = not plan.is_active
|
|||
|
|
plan.save(update_fields=['is_active', 'updated_at'])
|
|||
|
|
messages.success(request, f'已{"启用" if plan.is_active else "禁用"}套餐:{plan.name}')
|
|||
|
|
return redirect('admin_panel:plans')
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
@require_POST
|
|||
|
|
def plan_toggle_public(request, plan_id: int):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
plan = Plan.objects.filter(id=plan_id).first()
|
|||
|
|
if not plan:
|
|||
|
|
messages.error(request, '套餐不存在')
|
|||
|
|
return redirect('admin_panel:plans')
|
|||
|
|
plan.is_public = not plan.is_public
|
|||
|
|
plan.save(update_fields=['is_public', 'updated_at'])
|
|||
|
|
messages.success(request, f'已设置公开状态({"公开" if plan.is_public else "隐藏"}):{plan.name}')
|
|||
|
|
return redirect('admin_panel:plans')
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
@require_POST
|
|||
|
|
def plan_toggle_allow_new(request, plan_id: int):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
plan = Plan.objects.filter(id=plan_id).first()
|
|||
|
|
if not plan:
|
|||
|
|
messages.error(request, '套餐不存在')
|
|||
|
|
return redirect('admin_panel:plans')
|
|||
|
|
plan.allow_new_purchase = not plan.allow_new_purchase
|
|||
|
|
plan.save(update_fields=['allow_new_purchase', 'updated_at'])
|
|||
|
|
messages.success(request, f'已切换允许新购:{plan.name}')
|
|||
|
|
return redirect('admin_panel:plans')
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
@require_POST
|
|||
|
|
def plan_toggle_allow_renew(request, plan_id: int):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
plan = Plan.objects.filter(id=plan_id).first()
|
|||
|
|
if not plan:
|
|||
|
|
messages.error(request, '套餐不存在')
|
|||
|
|
return redirect('admin_panel:plans')
|
|||
|
|
plan.allow_renew = not plan.allow_renew
|
|||
|
|
plan.save(update_fields=['allow_renew', 'updated_at'])
|
|||
|
|
messages.success(request, f'已切换允许续费:{plan.name}')
|
|||
|
|
return redirect('admin_panel:plans')
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Users management
|
|||
|
|
@login_required
|
|||
|
|
def users_list(request):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
User = get_user_model()
|
|||
|
|
q = request.GET.get('q', '').strip()
|
|||
|
|
users_qs = User.objects.all()
|
|||
|
|
if q:
|
|||
|
|
users_qs = users_qs.filter(models.Q(username__icontains=q) | models.Q(email__icontains=q))
|
|||
|
|
users = users_qs.annotate(domain_count=Count('domains')).order_by('-is_staff', '-domain_count', '-date_joined')
|
|||
|
|
return render(request, 'admin_panel/users_list.html', {
|
|||
|
|
'users': users,
|
|||
|
|
'q': q,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Domains management
|
|||
|
|
@login_required
|
|||
|
|
def domains_list(request):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
q = request.GET.get('q', '').strip()
|
|||
|
|
user_id = request.GET.get('user_id')
|
|||
|
|
status = request.GET.get('status')
|
|||
|
|
|
|||
|
|
domains = Domain.objects.select_related('user', 'current_plan').all()
|
|||
|
|
if q:
|
|||
|
|
domains = domains.filter(name__icontains=q)
|
|||
|
|
if user_id:
|
|||
|
|
domains = domains.filter(user_id=user_id)
|
|||
|
|
if status:
|
|||
|
|
domains = domains.filter(status=status)
|
|||
|
|
|
|||
|
|
domains = domains.order_by('-updated_at')
|
|||
|
|
# 汇总用于筛选的状态列表
|
|||
|
|
status_choices = Domain.STATUS_CHOICES
|
|||
|
|
return render(request, 'admin_panel/domains_list.html', {
|
|||
|
|
'domains': domains,
|
|||
|
|
'q': q,
|
|||
|
|
'user_id': user_id,
|
|||
|
|
'status': status,
|
|||
|
|
'status_choices': status_choices,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
@require_POST
|
|||
|
|
@transaction.atomic
|
|||
|
|
def domain_toggle_suspend(request, domain_id: int):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
domain = Domain.objects.filter(id=domain_id).first()
|
|||
|
|
if not domain:
|
|||
|
|
messages.error(request, '域名不存在')
|
|||
|
|
return redirect('admin_panel:domains')
|
|||
|
|
if (request.POST.get('confirm') or '').strip().upper() != 'CONFIRM':
|
|||
|
|
messages.error(request, '请在确认框中输入 CONFIRM 以继续')
|
|||
|
|
return redirect('admin_panel:domains')
|
|||
|
|
if domain.status == Domain.STATUS_ACTIVE:
|
|||
|
|
domain.status = Domain.STATUS_SUSPENDED
|
|||
|
|
elif domain.status == Domain.STATUS_SUSPENDED:
|
|||
|
|
domain.status = Domain.STATUS_ACTIVE
|
|||
|
|
else:
|
|||
|
|
messages.warning(request, '当前状态不支持暂停/恢复切换')
|
|||
|
|
return redirect('admin_panel:domains')
|
|||
|
|
domain.save(update_fields=['status', 'updated_at'])
|
|||
|
|
try:
|
|||
|
|
OperationLog.objects.create(
|
|||
|
|
actor=request.user,
|
|||
|
|
action='domain_toggle_suspend',
|
|||
|
|
target=domain.name,
|
|||
|
|
detail=f"new_status={domain.status}"
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
logger.exception('operation log write failed')
|
|||
|
|
messages.success(request, f'域名状态已切换为:{dict(Domain.STATUS_CHOICES).get(domain.status, domain.status)}')
|
|||
|
|
return redirect('admin_panel:domains')
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
@transaction.atomic
|
|||
|
|
def domain_switch_plan(request, domain_id: int):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
domain = Domain.objects.filter(id=domain_id).select_related('current_plan').first()
|
|||
|
|
if not domain:
|
|||
|
|
messages.error(request, '域名不存在')
|
|||
|
|
return redirect('admin_panel:domains')
|
|||
|
|
if request.method == 'POST':
|
|||
|
|
form = DomainPlanSwitchForm(request.POST, instance=domain)
|
|||
|
|
if form.is_valid():
|
|||
|
|
old_plan = domain.current_plan
|
|||
|
|
form.save()
|
|||
|
|
try:
|
|||
|
|
OperationLog.objects.create(
|
|||
|
|
actor=request.user,
|
|||
|
|
action='domain_switch_plan',
|
|||
|
|
target=domain.name,
|
|||
|
|
detail=f"{old_plan.name if old_plan else '-'} → {domain.current_plan.name if domain.current_plan else '-'}"
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
logger.exception('operation log write failed')
|
|||
|
|
messages.success(request, f'已切换套餐:{old_plan.name if old_plan else "-"} → {domain.current_plan.name if domain.current_plan else "-"}')
|
|||
|
|
return redirect('admin_panel:domains')
|
|||
|
|
else:
|
|||
|
|
messages.error(request, '提交失败,请检查表单字段')
|
|||
|
|
else:
|
|||
|
|
form = DomainPlanSwitchForm(instance=domain)
|
|||
|
|
return render(request, 'admin_panel/domain_plan_switch.html', {
|
|||
|
|
'domain': domain,
|
|||
|
|
'form': form,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Domain: grant extra free traffic for current cycle
|
|||
|
|
@login_required
|
|||
|
|
@transaction.atomic
|
|||
|
|
def domain_grant_traffic(request, domain_id: int):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
domain = Domain.objects.filter(id=domain_id).first()
|
|||
|
|
if not domain:
|
|||
|
|
messages.error(request, '域名不存在')
|
|||
|
|
return redirect('admin_panel:domains')
|
|||
|
|
if request.method == 'POST':
|
|||
|
|
form = DomainGrantTrafficForm(request.POST, instance=domain)
|
|||
|
|
if form.is_valid():
|
|||
|
|
form.save()
|
|||
|
|
try:
|
|||
|
|
OperationLog.objects.create(
|
|||
|
|
actor=request.user,
|
|||
|
|
action='domain_grant_traffic',
|
|||
|
|
target=domain.name,
|
|||
|
|
detail=f"extra_free_traffic_gb_current_cycle={domain.extra_free_traffic_gb_current_cycle}"
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
logger.exception('operation log write failed')
|
|||
|
|
messages.success(request, f'已更新本周期额外赠送流量(GB):{domain.extra_free_traffic_gb_current_cycle}')
|
|||
|
|
return redirect('admin_panel:domains')
|
|||
|
|
else:
|
|||
|
|
messages.error(request, '提交失败,请检查表单字段')
|
|||
|
|
else:
|
|||
|
|
form = DomainGrantTrafficForm(instance=domain)
|
|||
|
|
return render(request, 'admin_panel/domain_grant_traffic.html', {
|
|||
|
|
'domain': domain,
|
|||
|
|
'form': form,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
@require_POST
|
|||
|
|
@transaction.atomic
|
|||
|
|
def domain_delete(request, domain_id: int):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
domain = Domain.objects.filter(id=domain_id).first()
|
|||
|
|
if not domain:
|
|||
|
|
messages.error(request, '域名不存在')
|
|||
|
|
return redirect('admin_panel:domains')
|
|||
|
|
if (request.POST.get('confirm') or '').strip().upper() != 'DELETE':
|
|||
|
|
messages.error(request, '请在确认框中输入 DELETE 以继续')
|
|||
|
|
return redirect('admin_panel:domains')
|
|||
|
|
domain.status = Domain.STATUS_DELETED if hasattr(Domain, 'STATUS_DELETED') else 'deleted'
|
|||
|
|
domain.save(update_fields=['status', 'updated_at'])
|
|||
|
|
try:
|
|||
|
|
OperationLog.objects.create(
|
|||
|
|
actor=request.user,
|
|||
|
|
action='domain_delete',
|
|||
|
|
target=domain.name,
|
|||
|
|
detail='soft-delete'
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
logger.exception('operation log write failed')
|
|||
|
|
messages.success(request, '域名已标记为删除')
|
|||
|
|
return redirect('admin_panel:domains')
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Billing management (admin)
|
|||
|
|
@login_required
|
|||
|
|
def invoices_list(request):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
status = request.GET.get('status')
|
|||
|
|
user_id = request.GET.get('user_id')
|
|||
|
|
qs = Invoice.objects.select_related('user').all()
|
|||
|
|
if status:
|
|||
|
|
qs = qs.filter(status=status)
|
|||
|
|
if user_id:
|
|||
|
|
qs = qs.filter(user_id=user_id)
|
|||
|
|
qs = qs.order_by('-period_end')
|
|||
|
|
totals = qs.aggregate(total_amount=Sum('amount_total'))
|
|||
|
|
return render(request, 'admin_panel/invoices_list.html', {
|
|||
|
|
'invoices': qs,
|
|||
|
|
'status': status,
|
|||
|
|
'user_id': user_id,
|
|||
|
|
'total_amount': totals.get('total_amount') or Decimal('0.00'),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
@transaction.atomic
|
|||
|
|
def invoice_detail_admin(request, invoice_id: int):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
invoice = Invoice.objects.select_related('user').filter(id=invoice_id).first()
|
|||
|
|
if not invoice:
|
|||
|
|
messages.error(request, '账单不存在')
|
|||
|
|
return redirect('admin_panel:billing_list')
|
|||
|
|
adj_form = InvoiceAdjustmentForm()
|
|||
|
|
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_admin',
|
|||
|
|
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')
|
|||
|
|
try:
|
|||
|
|
client = GoEdgeClient()
|
|||
|
|
for it in invoice.items.select_related('domain').all():
|
|||
|
|
if not it.domain or not it.domain.edge_server_id:
|
|||
|
|
continue
|
|||
|
|
web_id = client.find_server_web_id(int(it.domain.edge_server_id))
|
|||
|
|
if web_id:
|
|||
|
|
# 关闭停服与限速
|
|||
|
|
client.update_http_web_shutdown(web_id, {'isOn': False})
|
|||
|
|
client.update_http_web_request_limit(web_id, {'isOn': False})
|
|||
|
|
# 如域名被暂停,恢复为 active
|
|||
|
|
if it.domain.status == 'suspended':
|
|||
|
|
it.domain.status = 'active'
|
|||
|
|
it.domain.save(update_fields=['status', 'updated_at'])
|
|||
|
|
except Exception:
|
|||
|
|
logger.exception('auto recover after paid failed')
|
|||
|
|
messages.success(request, '账单已标记为已支付')
|
|||
|
|
return redirect('admin_panel:billing_detail', invoice_id=invoice.id)
|
|||
|
|
elif action == 'add_adjustment':
|
|||
|
|
if (request.POST.get('confirm') or '').strip().upper() != 'CONFIRM':
|
|||
|
|
messages.error(request, '请在确认框中输入 CONFIRM 以继续')
|
|||
|
|
return redirect('admin_panel:billing_detail', invoice_id=invoice.id)
|
|||
|
|
adj_form = InvoiceAdjustmentForm(request.POST)
|
|||
|
|
if adj_form.is_valid():
|
|||
|
|
adj_form.apply(invoice)
|
|||
|
|
try:
|
|||
|
|
OperationLog.objects.create(
|
|||
|
|
actor=request.user,
|
|||
|
|
action='invoice_add_adjustment',
|
|||
|
|
target=f"Invoice#{invoice.id}",
|
|||
|
|
detail=f"adjustment={invoice.amount_adjustment}; total={invoice.amount_total}"
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
logger.exception('operation log write failed')
|
|||
|
|
messages.success(request, '已添加人工调整并更新总金额')
|
|||
|
|
return redirect('admin_panel:billing_detail', invoice_id=invoice.id)
|
|||
|
|
else:
|
|||
|
|
messages.error(request, '调整提交失败,请检查表单字段')
|
|||
|
|
elif action == 'cancel' and invoice.status != Invoice.STATUS_CANCELLED:
|
|||
|
|
if (request.POST.get('confirm') or '').strip().upper() != 'CONFIRM':
|
|||
|
|
messages.error(request, '请在确认框中输入 CONFIRM 以继续')
|
|||
|
|
return redirect('admin_panel:billing_detail', invoice_id=invoice.id)
|
|||
|
|
invoice.status = Invoice.STATUS_CANCELLED
|
|||
|
|
invoice.save(update_fields=['status'])
|
|||
|
|
try:
|
|||
|
|
OperationLog.objects.create(
|
|||
|
|
actor=request.user,
|
|||
|
|
action='invoice_cancel',
|
|||
|
|
target=f"Invoice#{invoice.id}",
|
|||
|
|
detail=f"period={invoice.period_start}→{invoice.period_end}"
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
logger.exception('operation log write failed')
|
|||
|
|
messages.success(request, '账单已取消')
|
|||
|
|
return redirect('admin_panel:billing_detail', invoice_id=invoice.id)
|
|||
|
|
elif action == 'apply_overage_policy' and invoice.status == Invoice.STATUS_UNPAID:
|
|||
|
|
if (request.POST.get('confirm') or '').strip().upper() != 'CONFIRM':
|
|||
|
|
messages.error(request, '请在确认框中输入 CONFIRM 以继续')
|
|||
|
|
return redirect('admin_panel:billing_detail', invoice_id=invoice.id)
|
|||
|
|
sys = SystemSettings.objects.order_by('id').first()
|
|||
|
|
policy = getattr(sys, 'default_overage_policy', {}) or {}
|
|||
|
|
action_type = (policy.get('action') or '').lower()
|
|||
|
|
limit_bps = int(policy.get('limit_bps') or 0)
|
|||
|
|
applied = []
|
|||
|
|
try:
|
|||
|
|
client = GoEdgeClient()
|
|||
|
|
for it in invoice.items.select_related('domain').all():
|
|||
|
|
d = it.domain
|
|||
|
|
if not d or not d.edge_server_id:
|
|||
|
|
continue
|
|||
|
|
web_id = client.find_server_web_id(int(d.edge_server_id))
|
|||
|
|
if not web_id:
|
|||
|
|
continue
|
|||
|
|
if action_type == 'limit' and limit_bps > 0:
|
|||
|
|
# 最小限速配置(简化形态):开启限速并设定速率
|
|||
|
|
client.update_http_web_request_limit(web_id, {'isOn': True, 'rateBytes': int(limit_bps)})
|
|||
|
|
applied.append({'domain': d.name, 'action': 'limit', 'rateBytes': limit_bps})
|
|||
|
|
else:
|
|||
|
|
# 默认执行停服
|
|||
|
|
client.update_http_web_shutdown(web_id, {'isOn': True})
|
|||
|
|
# 域名状态置为暂停
|
|||
|
|
if d.status != 'suspended':
|
|||
|
|
d.status = 'suspended'
|
|||
|
|
d.save(update_fields=['status', 'updated_at'])
|
|||
|
|
applied.append({'domain': d.name, 'action': 'shutdown'})
|
|||
|
|
try:
|
|||
|
|
OperationLog.objects.create(
|
|||
|
|
actor=request.user,
|
|||
|
|
action='apply_overage_policy',
|
|||
|
|
target=f"Invoice#{invoice.id}",
|
|||
|
|
detail=json.dumps({'applied': applied}, ensure_ascii=False)
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
logger.exception('operation log write failed')
|
|||
|
|
messages.success(request, '已执行未支付策略(参考系统默认策略配置)')
|
|||
|
|
except Exception as e:
|
|||
|
|
messages.error(request, f'策略执行失败:{e}')
|
|||
|
|
return redirect('admin_panel:billing_detail', invoice_id=invoice.id)
|
|||
|
|
|
|||
|
|
items = invoice.items.select_related('domain').all()
|
|||
|
|
return render(request, 'admin_panel/invoice_detail.html', {
|
|||
|
|
'invoice': invoice,
|
|||
|
|
'items': items,
|
|||
|
|
'adj_form': adj_form,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Quotas view: show composed quotas (global/plan/user override/domain extra)
|
|||
|
|
@login_required
|
|||
|
|
def quotas_view(request):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
user_id = request.GET.get('user_id')
|
|||
|
|
domains = Domain.objects.select_related('user', 'current_plan').all()
|
|||
|
|
if user_id:
|
|||
|
|
domains = domains.filter(user_id=user_id)
|
|||
|
|
sys = SystemSettings.objects.order_by('id').first()
|
|||
|
|
rows = []
|
|||
|
|
for d in domains:
|
|||
|
|
plan_quota = int(getattr(d.current_plan, 'included_traffic_gb_per_domain', 0) or 0)
|
|||
|
|
user_override = 0
|
|||
|
|
profile = getattr(d.user, 'profile', None)
|
|||
|
|
if profile and profile.default_free_traffic_gb_per_domain_override is not None:
|
|||
|
|
user_override = int(profile.default_free_traffic_gb_per_domain_override)
|
|||
|
|
global_default = int(sys.default_free_traffic_gb_per_domain if sys else 0)
|
|||
|
|
base_quota = plan_quota if plan_quota > 0 else (user_override if user_override > 0 else global_default)
|
|||
|
|
domain_extra = int(d.extra_free_traffic_gb_current_cycle or 0)
|
|||
|
|
total_quota = base_quota + domain_extra
|
|||
|
|
rows.append({
|
|||
|
|
'domain': d,
|
|||
|
|
'plan_quota': plan_quota,
|
|||
|
|
'user_override': user_override,
|
|||
|
|
'global_default': global_default,
|
|||
|
|
'domain_extra': domain_extra,
|
|||
|
|
'total_quota': total_quota,
|
|||
|
|
})
|
|||
|
|
rows = sorted(rows, key=lambda r: (-(r['total_quota']), r['domain'].name))
|
|||
|
|
return render(request, 'admin_panel/quotas.html', {
|
|||
|
|
'rows': rows,
|
|||
|
|
'user_id': user_id,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Monitoring: platform daily totals and top-N domains
|
|||
|
|
@login_required
|
|||
|
|
def monitoring_view(request):
|
|||
|
|
if not _staff_only(request):
|
|||
|
|
return HttpResponseForbidden('需要运营权限(staff)')
|
|||
|
|
from domains.models import DomainTrafficDaily
|
|||
|
|
today = timezone.now().date()
|
|||
|
|
days = int(request.GET.get('days') or 14)
|
|||
|
|
start = today - timezone.timedelta(days=days-1)
|
|||
|
|
sys = SystemSettings.objects.order_by('id').first()
|
|||
|
|
anomaly_enabled = bool(getattr(sys, 'anomaly_detection_enabled', True))
|
|||
|
|
threshold_mult = float(getattr(sys, 'anomaly_threshold_multiplier', 3.0) or 3.0)
|
|||
|
|
window_days = int(getattr(sys, 'anomaly_window_days', 7) or 7)
|
|||
|
|
min_gb = float(getattr(sys, 'anomaly_min_gb', 1.0) or 1.0)
|
|||
|
|
# 平台每日总流量
|
|||
|
|
daily = (DomainTrafficDaily.objects
|
|||
|
|
.filter(day__gte=start, day__lte=today)
|
|||
|
|
.values('day')
|
|||
|
|
.annotate(total_bytes=Sum('bytes'))
|
|||
|
|
.order_by('day'))
|
|||
|
|
daily_rows = []
|
|||
|
|
for row in daily:
|
|||
|
|
gb = round((row['total_bytes'] or 0) / (1024 ** 3), 3)
|
|||
|
|
daily_rows.append({'day': row['day'], 'gb': gb})
|
|||
|
|
# Top N 域名(本月总量)
|
|||
|
|
month_start = today.replace(day=1)
|
|||
|
|
domains = Domain.objects.all()
|
|||
|
|
top = []
|
|||
|
|
anomalies = []
|
|||
|
|
for d in domains:
|
|||
|
|
bytes_sum = DomainTrafficDaily.objects.filter(domain=d, day__gte=month_start, day__lte=today).aggregate(b=Sum('bytes'))['b'] or 0
|
|||
|
|
gb = round(bytes_sum / (1024 ** 3), 3)
|
|||
|
|
top.append({'domain': d, 'gb': gb})
|
|||
|
|
|
|||
|
|
# 异常检测:今日 vs 过去窗口均值
|
|||
|
|
if anomaly_enabled:
|
|||
|
|
window_start = today - timezone.timedelta(days=window_days)
|
|||
|
|
past = (DomainTrafficDaily.objects
|
|||
|
|
.filter(domain=d, day__gte=window_start, day__lt=today)
|
|||
|
|
.aggregate(b=Sum('bytes'))['b'] or 0)
|
|||
|
|
past_days_count = DomainTrafficDaily.objects.filter(domain=d, day__gte=window_start, day__lt=today).count()
|
|||
|
|
past_avg_gb = 0.0
|
|||
|
|
if past_days_count > 0:
|
|||
|
|
past_avg_gb = round((past / (1024 ** 3)) / past_days_count, 3)
|
|||
|
|
today_bytes = DomainTrafficDaily.objects.filter(domain=d, day=today).aggregate(b=Sum('bytes'))['b'] or 0
|
|||
|
|
today_gb = round(today_bytes / (1024 ** 3), 3)
|
|||
|
|
trigger = False
|
|||
|
|
if past_avg_gb > 0:
|
|||
|
|
trigger = today_gb >= max(min_gb, past_avg_gb * threshold_mult)
|
|||
|
|
else:
|
|||
|
|
trigger = today_gb >= min_gb
|
|||
|
|
if trigger and today_gb > 0:
|
|||
|
|
anomalies.append({
|
|||
|
|
'domain': d,
|
|||
|
|
'today_gb': today_gb,
|
|||
|
|
'past_avg_gb': past_avg_gb,
|
|||
|
|
'ratio': round((today_gb / past_avg_gb), 2) if past_avg_gb > 0 else None,
|
|||
|
|
})
|
|||
|
|
top_sorted = sorted(top, key=lambda x: -x['gb'])[:20]
|
|||
|
|
return render(request, 'admin_panel/monitoring.html', {
|
|||
|
|
'daily_rows': daily_rows,
|
|||
|
|
'top_domains': top_sorted,
|
|||
|
|
'days': days,
|
|||
|
|
'anomalies': anomalies,
|
|||
|
|
'anomaly_config': {
|
|||
|
|
'enabled': anomaly_enabled,
|
|||
|
|
'multiplier': threshold_mult,
|
|||
|
|
'window_days': window_days,
|
|||
|
|
'min_gb': min_gb,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
logger = logging.getLogger(__name__)
|