Initial commit

This commit is contained in:
2025-11-18 03:36:49 +08:00
commit d17c7efb3c
7078 changed files with 831480 additions and 0 deletions

0
domains/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

19
domains/admin.py Normal file
View File

@@ -0,0 +1,19 @@
from django.contrib import admin
from .models import Domain, DomainTrafficDaily
@admin.register(Domain)
class DomainAdmin(admin.ModelAdmin):
list_display = (
'name', 'user', 'status', 'current_plan', 'edge_server_id', 'updated_at'
)
list_filter = ('status', 'current_plan')
search_fields = ('name', 'user__username')
@admin.register(DomainTrafficDaily)
class DomainTrafficDailyAdmin(admin.ModelAdmin):
list_display = ('domain', 'day', 'bytes', 'peak_bandwidth_mbps')
list_filter = ('day',)
# Register your models here.

6
domains/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DomainsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'domains'

66
domains/forms.py Normal file
View File

@@ -0,0 +1,66 @@
from django import forms
from plans.models import Plan
class AddDomainForm(forms.Form):
name = forms.CharField(label='主域名', max_length=253)
subdomains = forms.CharField(label='接入子域名(逗号分隔)', required=False, help_text='例如www,static')
origin_host = forms.CharField(label='源站地址域名或IP', max_length=253)
origin_protocol = forms.ChoiceField(label='回源协议', choices=[('http', 'HTTP'), ('https', 'HTTPS')], initial='http')
origin_port = forms.IntegerField(label='回源端口', initial=80, min_value=1, max_value=65535)
plan = forms.ModelChoiceField(
label='套餐',
queryset=Plan.objects.filter(is_active=True, is_public=True, allow_new_purchase=True),
required=False
)
enable_websocket = forms.BooleanField(label='启用WebSocket', required=False, initial=False)
def clean(self):
cleaned = super().clean()
# 规范化子域名列表
subs_raw = cleaned.get('subdomains') or ''
subs = [s.strip() for s in subs_raw.split(',') if s.strip()]
cleaned['subdomains'] = subs
return cleaned
class DomainSettingsForm(forms.Form):
waf_enabled = forms.BooleanField(label='启用 WAF', required=False)
http3_enabled = forms.BooleanField(label='启用 HTTP/3', required=False)
logs_enabled = forms.BooleanField(label='启用实时日志', required=False)
websocket_enabled = forms.BooleanField(label='启用 WebSocket', required=False)
redirect_https_enabled = forms.BooleanField(label='强制 HTTP→HTTPS 跳转', required=False)
cache_rules_json = forms.CharField(label='缓存规则JSON', required=False, widget=forms.Textarea(attrs={'rows': 6}))
ip_whitelist = forms.CharField(label='IP 白名单(逗号分隔)', required=False, widget=forms.Textarea(attrs={'rows': 3}))
ip_blacklist = forms.CharField(label='IP 黑名单(逗号分隔)', required=False, widget=forms.Textarea(attrs={'rows': 3}))
page_rules_json = forms.CharField(label='页面规则JSON', required=False, widget=forms.Textarea(attrs={'rows': 6}))
def clean_cache_rules_json(self):
val = self.cleaned_data.get('cache_rules_json') or ''
if not val.strip():
return {}
import json
try:
return json.loads(val)
except Exception:
raise forms.ValidationError('缓存规则需为合法 JSON')
def clean_page_rules_json(self):
val = self.cleaned_data.get('page_rules_json') or ''
if not val.strip():
return {}
import json
try:
return json.loads(val)
except Exception:
raise forms.ValidationError('页面规则需为合法 JSON')
def clean_ip_whitelist(self):
val = self.cleaned_data.get('ip_whitelist') or ''
ips = [s.strip() for s in val.split(',') if s.strip()]
return ips
def clean_ip_blacklist(self):
val = self.cleaned_data.get('ip_blacklist') or ''
ips = [s.strip() for s in val.split(',') if s.strip()]
return ips

View File

@@ -0,0 +1 @@
"""Django management package for domains app."""

View File

@@ -0,0 +1,67 @@
import datetime
from typing import Optional
from django.core.management.base import BaseCommand
from django.utils import timezone
from domains.models import Domain, DomainTrafficDaily
from core.goedge_client import GoEdgeClient
class Command(BaseCommand):
help = '从 GoEdge 拉取每日流量统计并写入 DomainTrafficDaily默认处理昨天的数据'
def add_arguments(self, parser):
parser.add_argument('--days', type=int, default=1, help='拉取最近N天默认1')
parser.add_argument('--server-id', type=int, default=0, help='仅处理指定的 serverId')
def handle(self, *args, **options):
days: int = options['days']
server_id_filter: int = options['server_id']
client = GoEdgeClient()
qs = Domain.objects.exclude(edge_server_id__isnull=True).exclude(edge_server_id__exact='')
if server_id_filter:
qs = qs.filter(edge_server_id=server_id_filter)
processed = 0
for domain in qs:
server_id = int(domain.edge_server_id)
try:
daily_stats = client.find_latest_server_daily_stats(server_id=server_id, days=days)
bw_stats = client.find_daily_bandwidth_stats(server_id=server_id, days=days, algo='avg')
except Exception as e:
self.stderr.write(self.style.ERROR(f'域名 {domain.name} 拉取失败: {e}'))
continue
# 将带宽按day映射
bw_by_day = {}
for s in bw_stats or []:
day = s.get('day')
bytes_per_sec = s.get('bytes') or 0
# 转换为 Mbpsbytes/sec * 8 / 1_000_000
mbps = (bytes_per_sec * 8) / 1_000_000 if bytes_per_sec else 0.0
bw_by_day[day] = mbps
for s in daily_stats or []:
day_str: Optional[str] = s.get('day') # YYYYMMDD
if not day_str:
continue
try:
day_date = datetime.datetime.strptime(day_str, '%Y%m%d').date()
except Exception:
continue
bytes_used = int(s.get('bytes') or 0)
peak_mbps = float(bw_by_day.get(day_str) or 0.0)
obj, created = DomainTrafficDaily.objects.update_or_create(
domain=domain,
day=day_date,
defaults={
'bytes': bytes_used,
'peak_bandwidth_mbps': peak_mbps,
}
)
processed += 1
self.stdout.write(self.style.SUCCESS(f'完成,写入/更新 {processed} 条日流量记录。'))

View File

@@ -0,0 +1,74 @@
import datetime
from typing import Dict
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from domains.models import Domain, DomainTrafficDaily
from core.goedge_client import GoEdgeClient
class Command(BaseCommand):
help = '从 GoEdge 读取各域名serverId的最近每日流量并写入 DomainTrafficDaily'
def add_arguments(self, parser):
parser.add_argument('--days', type=int, default=35, help='拉取最近N天数据默认35')
parser.add_argument('--only-active', action='store_true', dest='only_active', default=False, help='仅拉取状态为active的域名')
# 兼容文档中的别名参数
parser.add_argument('--active-only', action='store_true', dest='only_active', help='参数别名,等同 --only-active')
@transaction.atomic
def handle(self, *args, **options):
days: int = options['days']
only_active: bool = options['only_active']
qs = Domain.objects.exclude(edge_server_id__isnull=True)
if only_active:
qs = qs.filter(status=Domain.STATUS_ACTIVE)
domains = list(qs)
if not domains:
self.stdout.write(self.style.WARNING('没有可拉取的域名(缺少 edge_server_id'))
return
client = GoEdgeClient()
total_written = 0
for d in domains:
server_id = int(d.edge_server_id)
try:
stats = client.find_latest_server_daily_stats(server_id=server_id, days=days)
except Exception as e:
self.stderr.write(self.style.ERROR(f'[domain={d.name}] 拉取失败:{e}'))
continue
# 解析并入库
for s in stats:
day_str = s.get('day')
bytes_val = int(s.get('bytes') or 0)
peak_mbps = float(s.get('peakBandwidthMbps') or 0.0)
# 将 day 格式 YYYYMMDD 转为 date
try:
day_date = datetime.datetime.strptime(day_str, '%Y%m%d').date()
except Exception:
# 某些版本可能返回 YYYY-MM-DD
try:
day_date = datetime.datetime.strptime(day_str, '%Y-%m-%d').date()
except Exception:
self.stderr.write(self.style.WARNING(f'无法解析日期:{day_str}'))
continue
obj, created = DomainTrafficDaily.objects.update_or_create(
domain=d,
day=day_date,
defaults={
'bytes': bytes_val,
'peak_bandwidth_mbps': peak_mbps,
}
)
total_written += 1
self.stdout.write(self.style.SUCCESS(f'[domain={d.name}] 写入 {len(stats)}'))
self.stdout.write(self.style.SUCCESS(f'完成,总写入记录:{total_written}'))

View File

@@ -0,0 +1,73 @@
# Generated by Django 5.2.8 on 2025-11-07 07:39
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('plans', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Domain',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=253, unique=True)),
('status', models.CharField(choices=[('pending_dns', '等待DNS配置'), ('active', '正常'), ('suspended', '暂停'), ('deleted', '已删除')], default='pending_dns', max_length=20)),
('current_cycle_start', models.DateField(blank=True, null=True)),
('current_cycle_end', models.DateField(blank=True, null=True)),
('cname_targets', models.JSONField(blank=True, default=dict)),
('origin_config', models.JSONField(blank=True, default=dict)),
('edge_server_id', models.BigIntegerField(blank=True, null=True)),
('extra_free_traffic_gb_current_cycle', models.PositiveIntegerField(default=0)),
('custom_overage_price_per_gb', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('custom_features', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('current_plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='plans.plan')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': '域名',
'verbose_name_plural': '域名',
},
),
migrations.CreateModel(
name='DomainTrafficDaily',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('day', models.DateField()),
('bytes', models.BigIntegerField(default=0)),
('peak_bandwidth_mbps', models.FloatField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='daily_traffic', to='domains.domain')),
],
options={
'verbose_name': '域名日流量',
'verbose_name_plural': '域名日流量',
},
),
migrations.AddIndex(
model_name='domain',
index=models.Index(fields=['user', 'name'], name='domains_dom_user_id_840f80_idx'),
),
migrations.AddIndex(
model_name='domain',
index=models.Index(fields=['status'], name='domains_dom_status_e71c1c_idx'),
),
migrations.AddIndex(
model_name='domaintrafficdaily',
index=models.Index(fields=['domain', 'day'], name='domains_dom_domain__a38816_idx'),
),
migrations.AlterUniqueTogether(
name='domaintrafficdaily',
unique_together={('domain', 'day')},
),
]

View File

66
domains/models.py Normal file
View File

@@ -0,0 +1,66 @@
from django.db import models
from django.contrib.auth import get_user_model
from plans.models import Plan
User = get_user_model()
class Domain(models.Model):
STATUS_PENDING = 'pending_dns'
STATUS_ACTIVE = 'active'
STATUS_SUSPENDED = 'suspended'
STATUS_DELETED = 'deleted'
STATUS_CHOICES = [
(STATUS_PENDING, '等待DNS配置'),
(STATUS_ACTIVE, '正常'),
(STATUS_SUSPENDED, '暂停'),
(STATUS_DELETED, '已删除'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='domains')
name = models.CharField(max_length=253, unique=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING)
current_plan = models.ForeignKey(Plan, null=True, blank=True, on_delete=models.SET_NULL)
current_cycle_start = models.DateField(null=True, blank=True)
current_cycle_end = models.DateField(null=True, blank=True)
cname_targets = models.JSONField(default=dict, blank=True)
origin_config = models.JSONField(default=dict, blank=True)
edge_server_id = models.BigIntegerField(null=True, blank=True)
# overrides (optional)
extra_free_traffic_gb_current_cycle = models.PositiveIntegerField(default=0)
custom_overage_price_per_gb = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
custom_features = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '域名'
verbose_name_plural = '域名'
indexes = [
models.Index(fields=['user', 'name']),
models.Index(fields=['status']),
]
def __str__(self):
return self.name
class DomainTrafficDaily(models.Model):
domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name='daily_traffic')
day = models.DateField()
bytes = models.BigIntegerField(default=0)
peak_bandwidth_mbps = models.FloatField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = '域名日流量'
verbose_name_plural = '域名日流量'
unique_together = ('domain', 'day')
indexes = [
models.Index(fields=['domain', 'day']),
]
# Create your models here.

31
domains/tests.py Normal file
View File

@@ -0,0 +1,31 @@
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from django.urls import reverse
from unittest.mock import patch
from domains.models import Domain
from core.models import SystemSettings
User = get_user_model()
class DNSCheckTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='u1', email='u1@example.com', password='p')
SystemSettings.objects.create(cname_template='{sub}.cdn.example.com')
self.domain = Domain.objects.create(
user=self.user,
name='example.com',
status=Domain.STATUS_PENDING,
cname_targets={'www.example.com': 'www.cdn.example.com'},
)
def test_dns_check_updates_status(self):
c = Client()
c.login(username='u1', password='p')
with patch('core.utils.resolve_cname', return_value=['www.cdn.example.com']):
resp = c.post(reverse('domains:check_dns', kwargs={'domain_id': self.domain.id}))
self.assertEqual(resp.status_code, 200)
self.domain.refresh_from_db()
self.assertEqual(self.domain.status, Domain.STATUS_ACTIVE)

13
domains/urls.py Normal file
View File

@@ -0,0 +1,13 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.list_domains, name='list'),
path('add/', views.add_domain, name='add'),
path('<int:domain_id>/', views.domain_detail, name='detail'),
path('<int:domain_id>/check-dns/', views.check_dns, name='check_dns'),
path('<int:domain_id>/upgrade/', views.upgrade_plan, name='upgrade'),
path('<int:domain_id>/settings/', views.domain_settings, name='settings'),
path('<int:domain_id>/logs/', views.domain_logs, name='logs'),
]

565
domains/views.py Normal file
View File

@@ -0,0 +1,565 @@
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 '',
})