Files
2025-11-18 03:36:49 +08:00

566 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.shortcuts import render, redirect, get_object_or_404
import json
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.urls import reverse
from django.db import transaction
from django.utils import timezone
from .models import Domain, DomainTrafficDaily
from .forms import AddDomainForm, DomainSettingsForm
from core.goedge_client import GoEdgeClient
from core.models import SystemSettings, OperationLog
from plans.models import Plan
from core.utils import check_cname_map, bytes_to_gb
@login_required
def list_domains(request):
domains = Domain.objects.filter(user=request.user).order_by('-created_at')
# 计算当月用量与配额
today = timezone.now().date()
month_start = today.replace(day=1)
sys = SystemSettings.objects.order_by('id').first()
for d in domains:
traffic_qs = DomainTrafficDaily.objects.filter(domain=d, day__gte=month_start)
total_bytes = sum(t.bytes for t in traffic_qs)
used_gb = bytes_to_gb(total_bytes)
base_quota_gb = 0
if d.current_plan and d.current_plan.included_traffic_gb_per_domain:
base_quota_gb = d.current_plan.included_traffic_gb_per_domain
elif sys:
base_quota_gb = sys.default_free_traffic_gb_per_domain
total_quota_gb = (base_quota_gb or 0) + (d.extra_free_traffic_gb_current_cycle or 0)
# 动态属性用于模板展示
d.used_gb = used_gb
d.total_quota_gb = total_quota_gb
return render(request, 'domains/list.html', {
'domains': domains,
})
@login_required
@transaction.atomic
def add_domain(request):
if request.method == 'POST':
form = AddDomainForm(request.POST)
if form.is_valid():
name = form.cleaned_data['name'].strip()
subdomains = form.cleaned_data['subdomains']
origin_host = form.cleaned_data['origin_host'].strip()
origin_protocol = form.cleaned_data['origin_protocol']
origin_port = form.cleaned_data['origin_port']
plan = form.cleaned_data.get('plan')
enable_websocket = form.cleaned_data.get('enable_websocket') or False
# 构造域名列表:至少包含一个主域名或子域名
domains_list = []
if subdomains:
domains_list.extend([f"{sub}.{name}" for sub in subdomains])
else:
domains_list.append(name)
# 构造源站地址列表
origin_addr = f"{origin_protocol}://{origin_host}:{origin_port}"
origin_addrs = [origin_addr]
# 初始化 GoEdge 客户端并创建服务
try:
client = GoEdgeClient()
server_id = client.create_basic_http_server(
domains=domains_list,
origin_addrs=origin_addrs,
user_id=0, # 管理员创建,不指定用户
enable_websocket=enable_websocket,
)
except Exception as e:
messages.error(request, f'创建加速服务失败:{e}')
return render(request, 'domains/add.html', {'form': form})
# 保存域名记录
sys = SystemSettings.objects.order_by('id').first()
cname_template = (sys.cname_template if sys else '{sub}.cdn.example.com')
cname_map = {}
for sub in subdomains or []:
cname_map[f"{sub}.{name}"] = cname_template.replace('{sub}', sub)
if not cname_map:
# 主域名CNAME场景可选
cname_map[name] = cname_template.replace('{sub}', 'www')
# 计费周期:设置为当前自然月
today = timezone.now().date()
month_start = today.replace(day=1)
# 下个月第一天
if month_start.month == 12:
next_month_start = month_start.replace(year=month_start.year + 1, month=1, day=1)
else:
next_month_start = month_start.replace(month=month_start.month + 1, day=1)
current_cycle_end = next_month_start - timezone.timedelta(days=1)
domain = Domain.objects.create(
user=request.user,
name=name,
status=Domain.STATUS_PENDING,
current_plan=plan,
current_cycle_start=month_start,
current_cycle_end=current_cycle_end,
cname_targets=cname_map,
origin_config={
'host': origin_host,
'protocol': origin_protocol,
'port': origin_port,
},
edge_server_id=server_id,
)
# 记录操作日志
try:
OperationLog.objects.create(
actor=request.user,
action='create_domain',
target=name,
detail=f"server_id={server_id}; origin={origin_protocol}://{origin_host}:{origin_port}; subdomains={','.join(subdomains or [])}",
)
except Exception:
# 日志写入失败不阻断主流程
pass
messages.success(request, '域名已创建请按提示配置DNS CNAME记录并等待生效。')
return redirect(reverse('domains:detail', kwargs={'domain_id': domain.id}))
else:
form = AddDomainForm()
return render(request, 'domains/add.html', {'form': form})
@login_required
def domain_detail(request, domain_id: int):
domain = get_object_or_404(Domain, id=domain_id, user=request.user)
# 流量统计:当月(按自然月)
today = timezone.now().date()
month_start = today.replace(day=1)
traffic_qs = DomainTrafficDaily.objects.filter(domain=domain, day__gte=month_start)
total_bytes = sum(t.bytes for t in traffic_qs)
used_gb = bytes_to_gb(total_bytes)
# 配额计算Plan 或系统默认 + 域名当期额外
sys = SystemSettings.objects.order_by('id').first()
base_quota_gb = 0
if domain.current_plan and domain.current_plan.included_traffic_gb_per_domain:
base_quota_gb = domain.current_plan.included_traffic_gb_per_domain
elif sys:
base_quota_gb = sys.default_free_traffic_gb_per_domain
total_quota_gb = (base_quota_gb or 0) + (domain.extra_free_traffic_gb_current_cycle or 0)
progress_pct = 0
if total_quota_gb > 0:
progress_pct = int(min(100, (used_gb / total_quota_gb) * 100))
# 近7/30天统计与峰值跨自然月
seven_start = today - timezone.timedelta(days=6)
thirty_start = today - timezone.timedelta(days=29)
last7_qs = DomainTrafficDaily.objects.filter(domain=domain, day__gte=seven_start, day__lte=today)
last30_qs = DomainTrafficDaily.objects.filter(domain=domain, day__gte=thirty_start, day__lte=today)
last7_total_gb = bytes_to_gb(sum(t.bytes for t in last7_qs))
last30_total_gb = bytes_to_gb(sum(t.bytes for t in last30_qs))
# 平均值GB/日)
last7_count = last7_qs.count()
last30_count = last30_qs.count()
last7_avg_gb = round((last7_total_gb / last7_count), 2) if last7_count else 0
last30_avg_gb = round((last30_total_gb / last30_count), 2) if last30_count else 0
peak_volume_record = last30_qs.order_by('-bytes').first()
peak_bandwidth_record = last30_qs.order_by('-peak_bandwidth_mbps').first()
peak_volume_day = None
peak_bandwidth_day = None
if peak_volume_record:
peak_volume_day = {
'day': peak_volume_record.day,
'gb': bytes_to_gb(peak_volume_record.bytes),
}
if peak_bandwidth_record:
peak_bandwidth_day = {
'day': peak_bandwidth_record.day,
'mbps': peak_bandwidth_record.peak_bandwidth_mbps,
}
# 近30天每日统计GB 与峰值带宽)
last_30 = list(last30_qs.order_by('-day'))
traffic_rows = []
for t in last_30:
gb = bytes_to_gb(t.bytes)
traffic_rows.append({'day': t.day, 'gb': gb, 'peak_mbps': t.peak_bandwidth_mbps})
# GoEdge 同步状态
goedge_status = None
if domain.edge_server_id:
try:
client = GoEdgeClient()
goedge_status = client.get_server_feature_status(domain.edge_server_id)
except Exception as e:
goedge_status = {'error': str(e)}
requests_24h_total = None
status_bins = None
status_top = None
if domain.edge_server_id:
try:
client = GoEdgeClient()
hs = client.find_latest_server_hourly_stats(int(domain.edge_server_id), hours=24)
requests_24h_total = sum(int(x.get('countRequests') or 0) for x in hs)
agg = client.aggregate_status_codes(int(domain.edge_server_id))
status_bins = agg.get('bins')
status_top = agg.get('top')
except Exception:
pass
context = {
'domain': domain,
'used_gb': used_gb,
'total_quota_gb': total_quota_gb,
'progress_pct': progress_pct,
'traffic_rows': traffic_rows,
'last7_total_gb': last7_total_gb,
'last30_total_gb': last30_total_gb,
'last7_avg_gb': last7_avg_gb,
'last30_avg_gb': last30_avg_gb,
'peak_volume_day': peak_volume_day,
'peak_bandwidth_day': peak_bandwidth_day,
'cname_results': None,
'goedge_status': goedge_status,
'requests_24h_total': requests_24h_total,
'status_bins': status_bins,
'status_top': status_top,
}
return render(request, 'domains/detail.html', context)
@login_required
@transaction.atomic
def check_dns(request, domain_id: int):
domain = get_object_or_404(Domain, id=domain_id, user=request.user)
if request.method != 'POST':
return redirect(reverse('domains:detail', kwargs={'domain_id': domain.id}))
cname_map = domain.cname_targets or {}
results = check_cname_map(cname_map)
ok_count = sum(1 for r in results if r.get('ok'))
total = len(results)
if ok_count == total and total > 0:
messages.success(request, 'DNS 已生效,所有 CNAME 指向正确。')
# 如果当前为等待DNS可切换为 active
if domain.status == Domain.STATUS_PENDING:
domain.status = Domain.STATUS_ACTIVE
domain.save(update_fields=['status'])
else:
messages.warning(request, f'DNS 检测完成:{ok_count}/{total} 条记录正确。请检查未生效的记录。')
# 重渲染详情页并显示检测结果
today = timezone.now().date()
month_start = today.replace(day=1)
traffic_qs = DomainTrafficDaily.objects.filter(domain=domain, day__gte=month_start)
total_bytes = sum(t.bytes for t in traffic_qs)
used_gb = bytes_to_gb(total_bytes)
sys = SystemSettings.objects.order_by('id').first()
base_quota_gb = 0
if domain.current_plan and domain.current_plan.included_traffic_gb_per_domain:
base_quota_gb = domain.current_plan.included_traffic_gb_per_domain
elif sys:
base_quota_gb = sys.default_free_traffic_gb_per_domain
total_quota_gb = (base_quota_gb or 0) + (domain.extra_free_traffic_gb_current_cycle or 0)
progress_pct = 0
if total_quota_gb > 0:
progress_pct = int(min(100, (used_gb / total_quota_gb) * 100))
seven_start = today - timezone.timedelta(days=6)
thirty_start = today - timezone.timedelta(days=29)
last30_qs = DomainTrafficDaily.objects.filter(domain=domain, day__gte=thirty_start, day__lte=today)
last7_qs = DomainTrafficDaily.objects.filter(domain=domain, day__gte=seven_start, day__lte=today)
peak_volume_record = last30_qs.order_by('-bytes').first()
peak_bandwidth_record = last30_qs.order_by('-peak_bandwidth_mbps').first()
return render(request, 'domains/detail.html', {
'domain': domain,
'used_gb': used_gb,
'total_quota_gb': total_quota_gb,
'progress_pct': progress_pct,
'traffic_rows': [{
'day': t.day,
'gb': bytes_to_gb(t.bytes),
'peak_mbps': t.peak_bandwidth_mbps,
} for t in last30_qs.order_by('-day')],
'last7_total_gb': bytes_to_gb(sum(t.bytes for t in last7_qs)),
'last30_total_gb': bytes_to_gb(sum(t.bytes for t in last30_qs)),
'last7_avg_gb': (round(bytes_to_gb(sum(t.bytes for t in last7_qs)) / last7_qs.count(), 2) if last7_qs.count() else 0),
'last30_avg_gb': (round(bytes_to_gb(sum(t.bytes for t in last30_qs)) / last30_qs.count(), 2) if last30_qs.count() else 0),
'peak_volume_day': ({
'day': peak_volume_record.day,
'gb': bytes_to_gb(peak_volume_record.bytes),
} if peak_volume_record else None),
'peak_bandwidth_day': ({
'day': peak_bandwidth_record.day,
'mbps': peak_bandwidth_record.peak_bandwidth_mbps,
} if peak_bandwidth_record else None),
'cname_results': results,
})
# Create your views here.
@login_required
@transaction.atomic
def upgrade_plan(request, domain_id: int):
"""最小套餐升级流程:展示公开套餐并允许用户选择变更当前域名的套餐。"""
domain = get_object_or_404(Domain, id=domain_id, user=request.user)
# 可选套餐列表(公开且激活)
available_plans = Plan.objects.filter(is_active=True, is_public=True).order_by('base_price_per_domain')
if request.method == 'POST':
plan_id = request.POST.get('plan_id')
try:
new_plan = available_plans.get(id=plan_id)
except Plan.DoesNotExist:
messages.error(request, '选择的套餐不可用或不存在。')
return render(request, 'domains/upgrade.html', {
'domain': domain,
'plans': available_plans,
})
old_plan = domain.current_plan
domain.current_plan = new_plan
domain.save(update_fields=['current_plan', 'updated_at'])
# 操作日志
try:
OperationLog.objects.create(
actor=request.user,
action='upgrade_plan',
target=domain.name,
detail=f"from={old_plan.name if old_plan else '-'} to={new_plan.name}",
)
except Exception:
pass
messages.success(request, f'套餐已升级为:{new_plan.name}')
return redirect(reverse('domains:detail', kwargs={'domain_id': domain.id}))
return render(request, 'domains/upgrade.html', {
'domain': domain,
'plans': available_plans,
})
@login_required
@transaction.atomic
def domain_settings(request, domain_id: int):
"""域名功能设置页:保存到 Domain.custom_features后续接入 GoEdge 配置更新。"""
domain = get_object_or_404(Domain, id=domain_id)
# 权限仅域名所有者或staff
if not (domain.user_id == request.user.id or request.user.is_staff):
messages.warning(request, '无权编辑该域名设置。')
return redirect('domains:detail', domain_id=domain.id)
plan_features = domain.current_plan.features if domain.current_plan else {}
import json
initial = {
'waf_enabled': bool(domain.custom_features.get('waf_enabled', plan_features.get('waf_enabled', False))),
'http3_enabled': bool(domain.custom_features.get('http3_enabled', plan_features.get('http3_enabled', False))),
'logs_enabled': bool(domain.custom_features.get('logs_enabled', plan_features.get('logs_enabled', False))),
'websocket_enabled': bool(domain.custom_features.get('websocket_enabled', plan_features.get('websocket_enabled', False))),
'redirect_https_enabled': bool(domain.custom_features.get('redirect_https_enabled', False)),
'cache_rules_json': json.dumps(domain.custom_features.get('cache_rules_json', {}), ensure_ascii=False),
'page_rules_json': json.dumps(domain.custom_features.get('page_rules_json', {}), ensure_ascii=False),
'ip_whitelist': ','.join(domain.custom_features.get('ip_whitelist', [])),
'ip_blacklist': ','.join(domain.custom_features.get('ip_blacklist', [])),
}
if request.method == 'POST':
form = DomainSettingsForm(request.POST)
if form.is_valid():
data = form.cleaned_data
domain.custom_features = {
'waf_enabled': bool(data.get('waf_enabled')),
'http3_enabled': bool(data.get('http3_enabled')),
'logs_enabled': bool(data.get('logs_enabled')),
'websocket_enabled': bool(data.get('websocket_enabled')),
'redirect_https_enabled': bool(data.get('redirect_https_enabled')),
'cache_rules_json': data.get('cache_rules_json') or {},
'page_rules_json': data.get('page_rules_json') or {},
'ip_whitelist': data.get('ip_whitelist') or [],
'ip_blacklist': data.get('ip_blacklist') or [],
}
domain.save(update_fields=['custom_features', 'updated_at'])
# 记录操作日志含策略ID与同步结果
log_detail = {
'requested': {
'waf_enabled': bool(data.get('waf_enabled')),
'http3_enabled': bool(data.get('http3_enabled')),
'logs_enabled': bool(data.get('logs_enabled')),
'websocket_enabled': bool(data.get('websocket_enabled')),
},
'sync': {
'accessLog': None,
'websocket': None,
'firewall': None,
'ssl_http3': None,
}
}
# 尝试同步到 GoEdge根据可用策略与配置
try:
client = GoEdgeClient()
web_id = client.find_server_web_id(domain.edge_server_id)
if web_id:
sys = SystemSettings.objects.order_by('id').first()
# 访问日志
client.update_http_web_access_log(
http_web_id=web_id,
is_on=bool(data.get('logs_enabled')),
policy_id=(sys.default_http_access_log_policy_id if sys else None)
)
log_detail['sync']['accessLog'] = {
'webId': web_id,
'isOn': bool(data.get('logs_enabled')),
'policyId': (sys.default_http_access_log_policy_id if sys else None)
}
# WebSocket
client.update_http_web_websocket(http_web_id=web_id, is_on=bool(data.get('websocket_enabled')))
log_detail['sync']['websocket'] = {'webId': web_id, 'isOn': bool(data.get('websocket_enabled'))}
# WAF如有默认策略则引用
client.update_http_web_firewall(
http_web_id=web_id,
is_on=bool(data.get('waf_enabled')),
policy_id=(sys.default_http_firewall_policy_id if sys else None)
)
log_detail['sync']['firewall'] = {
'webId': web_id,
'isOn': bool(data.get('waf_enabled')),
'policyId': (sys.default_http_firewall_policy_id if sys else None)
}
cache_conf = data.get('cache_rules_json') or {}
if isinstance(cache_conf, dict) and cache_conf:
client.update_http_web_cache(http_web_id=web_id, cache_conf=cache_conf)
log_detail['sync']['cache'] = {'webId': web_id, 'applied': True}
page_rules = data.get('page_rules_json') or {}
if isinstance(page_rules, dict) and page_rules:
locations_conf = page_rules.get('locations') or page_rules.get('Locations') or None
rewrite_conf = page_rules.get('rewriteRules') or page_rules.get('RewriteRules') or None
if locations_conf:
client.update_http_web_locations(http_web_id=web_id, locations_conf=locations_conf)
log_detail['sync']['locations'] = {'webId': web_id, 'count': len(locations_conf) if isinstance(locations_conf, list) else 1}
if rewrite_conf:
client.update_http_web_rewrite_rules(http_web_id=web_id, rewrite_conf=rewrite_conf)
log_detail['sync']['rewrite'] = {'webId': web_id, 'count': len(rewrite_conf) if isinstance(rewrite_conf, list) else 1}
# 强制 HTTPS 跳转
if bool(data.get('redirect_https_enabled')):
client.update_http_web_redirect_to_https(http_web_id=web_id, redirect_conf={'isOn': True})
log_detail['sync']['redirectToHTTPS'] = {'webId': web_id, 'isOn': True}
else:
client.update_http_web_redirect_to_https(http_web_id=web_id, redirect_conf={'isOn': False})
log_detail['sync']['redirectToHTTPS'] = {'webId': web_id, 'isOn': False}
# HTTP/3 通过 SSL 策略更新
ssl_policy_id = client.find_server_ssl_policy_id(domain.edge_server_id)
if ssl_policy_id is not None:
client.update_ssl_policy_http3(ssl_policy_id, bool(data.get('http3_enabled')))
log_detail['sync']['ssl_http3'] = {'sslPolicyId': ssl_policy_id, 'http3Enabled': bool(data.get('http3_enabled'))}
except Exception as e:
messages.warning(request, f'部分功能未同步到 GoEdge{e}')
log_detail['sync']['error'] = str(e)
# 写操作日志
try:
OperationLog.objects.create(
actor=request.user,
action='domain_settings_update',
target=domain.name,
detail=json.dumps(log_detail, ensure_ascii=False)
)
except Exception:
pass
messages.success(request, '设置已保存。部分功能需对应套餐支持方可生效。')
return redirect('domains:settings', domain_id=domain.id)
else:
messages.error(request, '保存失败,请检查表单字段。')
else:
form = DomainSettingsForm(initial=initial)
return render(request, 'domains/settings.html', {
'domain': domain,
'form': form,
'plan_features': plan_features,
})
@login_required
def domain_logs(request, domain_id: int):
domain = get_object_or_404(Domain, id=domain_id, user=request.user)
server_id = int(domain.edge_server_id or 0)
logs = []
request_id = request.GET.get('request_id') or None
day = request.GET.get('day') or None
hour_from = request.GET.get('hour_from') or None
hour_to = request.GET.get('hour_to') or None
ip = request.GET.get('ip') or None
keyword = request.GET.get('keyword') or None
size = int(request.GET.get('size') or 50)
status_code = request.GET.get('status_code') or ''
export = request.GET.get('export') or ''
reverse = bool(request.GET.get('reverse'))
has_more = False
if server_id:
try:
client = GoEdgeClient()
res = client.list_http_access_logs(
server_id=server_id,
day=day,
size=size,
hour_from=hour_from,
hour_to=hour_to,
reverse=reverse,
ip=ip,
keyword=keyword,
request_id=request_id,
)
logs = res.get('logs') or []
request_id = res.get('requestId') or None
has_more = bool(res.get('hasMore'))
except Exception as e:
messages.warning(request, f'访问日志查询失败:{e}')
# 过滤状态码
if status_code:
try:
sc = int(status_code)
logs = [l for l in (logs or []) if int(l.get('status') or 0) == sc]
except Exception:
pass
# 导出
if export == 'csv':
import csv
from django.http import HttpResponse
resp = HttpResponse(content_type='text/csv; charset=utf-8')
resp['Content-Disposition'] = f'attachment; filename="{domain.name}_access_logs.csv"'
writer = csv.writer(resp)
writer.writerow(['timeLocal', 'host', 'remoteAddr', 'method', 'requestURI', 'status', 'bytesSent', 'userAgent'])
for l in logs:
writer.writerow([
l.get('timeLocal'), l.get('host'), l.get('remoteAddr'), l.get('method'), l.get('requestURI'), l.get('status'), l.get('bytesSent'), l.get('userAgent'),
])
return resp
if export == 'json':
from django.http import JsonResponse
return JsonResponse({'domain': domain.name, 'logs': logs}, json_dumps_params={'ensure_ascii': False})
return render(request, 'domains/logs.html', {
'domain': domain,
'logs': logs,
'day': day or '',
'hour_from': hour_from or '',
'hour_to': hour_to or '',
'ip': ip or '',
'keyword': keyword or '',
'size': size,
'reverse': reverse,
'request_id': request_id or '',
'has_more': has_more,
'status_code': status_code or '',
})