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 '', })