Initial commit
This commit is contained in:
0
billing/__init__.py
Normal file
0
billing/__init__.py
Normal file
BIN
billing/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
billing/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
billing/__pycache__/admin.cpython-312.pyc
Normal file
BIN
billing/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
billing/__pycache__/apps.cpython-312.pyc
Normal file
BIN
billing/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
billing/__pycache__/models.cpython-312.pyc
Normal file
BIN
billing/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
billing/__pycache__/tests.cpython-312.pyc
Normal file
BIN
billing/__pycache__/tests.cpython-312.pyc
Normal file
Binary file not shown.
BIN
billing/__pycache__/urls.cpython-312.pyc
Normal file
BIN
billing/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
billing/__pycache__/views.cpython-312.pyc
Normal file
BIN
billing/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
21
billing/admin.py
Normal file
21
billing/admin.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.contrib import admin
|
||||
from .models import Invoice, InvoiceItem
|
||||
|
||||
|
||||
class InvoiceItemInline(admin.TabularInline):
|
||||
model = InvoiceItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Invoice)
|
||||
class InvoiceAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'period_start', 'period_end', 'amount_total', 'status', 'created_at')
|
||||
list_filter = ('status',)
|
||||
inlines = [InvoiceItemInline]
|
||||
|
||||
|
||||
@admin.register(InvoiceItem)
|
||||
class InvoiceItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('invoice', 'domain', 'description', 'quantity', 'unit_price', 'amount')
|
||||
|
||||
# Register your models here.
|
||||
6
billing/apps.py
Normal file
6
billing/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BillingConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'billing'
|
||||
1
billing/management/__init__.py
Normal file
1
billing/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Django management package for billing app."""
|
||||
BIN
billing/management/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
billing/management/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
83
billing/management/commands/apply_invoice_policies.py
Normal file
83
billing/management/commands/apply_invoice_policies.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
|
||||
from billing.models import Invoice
|
||||
from core.models import SystemSettings, OperationLog
|
||||
from core.goedge_client import GoEdgeClient
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '对逾期未支付账单执行未支付策略(停服或限速)。支持 dry-run 与用户/时间过滤。'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--days-overdue', type=int, default=0, help='逾期天数阈值(默认0,表示只要未支付即处理)')
|
||||
parser.add_argument('--user-id', type=int, default=0, help='仅处理指定用户(可选)')
|
||||
parser.add_argument('--dry-run', action='store_true', default=False, help='试运行,不落库与不调用接口')
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
days_overdue = int(options.get('days_overdue') or 0)
|
||||
user_id = int(options.get('user_id') or 0)
|
||||
dry = bool(options.get('dry_run'))
|
||||
|
||||
today = timezone.now().date()
|
||||
qs = Invoice.objects.filter(status=Invoice.STATUS_UNPAID)
|
||||
if user_id:
|
||||
qs = qs.filter(user_id=user_id)
|
||||
if days_overdue > 0:
|
||||
# 以账单周期结束日为逾期基准
|
||||
cutoff = today - timezone.timedelta(days=days_overdue)
|
||||
qs = qs.filter(period_end__lte=cutoff)
|
||||
|
||||
invoices = list(qs)
|
||||
if not invoices:
|
||||
self.stdout.write(self.style.NOTICE('无符合条件的未支付账单'))
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
client = None
|
||||
if not dry:
|
||||
client = GoEdgeClient()
|
||||
|
||||
total_domains = 0
|
||||
for inv in invoices:
|
||||
applied = []
|
||||
items = inv.items.select_related('domain').all()
|
||||
for it in items:
|
||||
d = it.domain
|
||||
if not d or not d.edge_server_id:
|
||||
continue
|
||||
total_domains += 1
|
||||
if dry:
|
||||
applied.append({'domain': d.name, 'action': action_type or 'shutdown', 'limit_bps': limit_bps})
|
||||
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'})
|
||||
if applied:
|
||||
try:
|
||||
OperationLog.objects.create(
|
||||
actor=None,
|
||||
action='apply_overage_policy_cron',
|
||||
target=f"Invoice#{inv.id}",
|
||||
detail=str(applied)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self.stdout.write(self.style.SUCCESS(f"Invoice#{inv.id} 应用策略:{len(applied)} 个域名"))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'完成:处理账单 {len(invoices)} 个,涉及域名 {total_domains} 个'))
|
||||
200
billing/management/commands/generate_invoices.py
Normal file
200
billing/management/commands/generate_invoices.py
Normal file
@@ -0,0 +1,200 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from billing.models import Invoice, InvoiceItem
|
||||
from domains.models import Domain, DomainTrafficDaily
|
||||
from core.models import SystemSettings
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def month_range_from_str(month_str: str):
|
||||
try:
|
||||
year, month = map(int, month_str.split('-'))
|
||||
start = date(year, month, 1)
|
||||
except Exception as e:
|
||||
raise CommandError(f"无效的 --month 参数,格式应为 YYYY-MM:{e}")
|
||||
# 下月第一天
|
||||
if month == 12:
|
||||
next_month_start = date(year + 1, 1, 1)
|
||||
else:
|
||||
next_month_start = date(year, month + 1, 1)
|
||||
end = next_month_start - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
|
||||
def previous_month_range():
|
||||
today = timezone.now().date()
|
||||
this_month_start = today.replace(day=1)
|
||||
last_day_prev = this_month_start - timedelta(days=1)
|
||||
start_prev = last_day_prev.replace(day=1)
|
||||
return start_prev, last_day_prev
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "按自然月生成用户账单(套餐费 + 超量流量)。默认生成上一自然月,可用 --month 指定。"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--month', type=str, help='计费月份,格式 YYYY-MM,如 2025-10')
|
||||
parser.add_argument('--user-id', type=int, help='仅为指定用户生成账单(可选)')
|
||||
parser.add_argument('--overwrite', action='store_true', help='若已存在账单则覆盖重建(删除后重建)')
|
||||
parser.add_argument('--dry-run', action='store_true', help='试运行,仅输出计算结果,不写入数据库')
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
# 计费周期
|
||||
if options.get('month'):
|
||||
period_start, period_end = month_range_from_str(options['month'])
|
||||
else:
|
||||
period_start, period_end = previous_month_range()
|
||||
|
||||
user_id = options.get('user_id')
|
||||
overwrite = options.get('overwrite', False)
|
||||
dry_run = options.get('dry_run', False)
|
||||
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
f"生成账单周期:{period_start} → {period_end}"
|
||||
))
|
||||
|
||||
users_qs = User.objects.all()
|
||||
if user_id:
|
||||
users_qs = users_qs.filter(id=user_id)
|
||||
if not users_qs.exists():
|
||||
raise CommandError(f"用户 {user_id} 不存在")
|
||||
|
||||
sys = SystemSettings.objects.order_by('id').first()
|
||||
|
||||
total_invoices = 0
|
||||
total_amount = Decimal('0.00')
|
||||
|
||||
for user in users_qs:
|
||||
# 处理已存在账单
|
||||
existing = Invoice.objects.filter(user=user, period_start=period_start, period_end=period_end).first()
|
||||
if existing and not overwrite:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"跳过用户 {user.id}({user.username}),该周期账单已存在:Invoice#{existing.id}"
|
||||
))
|
||||
continue
|
||||
|
||||
if existing and overwrite and not dry_run:
|
||||
existing.delete()
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"已删除旧账单:用户 {user.id}({user.username}) Invoice#{existing.id}"
|
||||
))
|
||||
|
||||
# 汇总用户域名的费用
|
||||
domains = Domain.objects.filter(user=user, status=Domain.STATUS_ACTIVE)
|
||||
amount_plan_total = Decimal('0.00')
|
||||
amount_overage_total = Decimal('0.00')
|
||||
items = []
|
||||
|
||||
for d in domains:
|
||||
plan = d.current_plan
|
||||
base_price = Decimal(str(plan.base_price_per_domain)) if plan and plan.base_price_per_domain is not None else Decimal('0.00')
|
||||
included_gb = Decimal(str(plan.included_traffic_gb_per_domain)) if plan and plan.included_traffic_gb_per_domain is not None else None
|
||||
overage_price = Decimal(str(plan.overage_price_per_gb)) if plan and plan.overage_price_per_gb is not None else Decimal('0.00')
|
||||
|
||||
# 若域名自定义超量单价优先
|
||||
if d.custom_overage_price_per_gb is not None:
|
||||
overage_price = Decimal(str(d.custom_overage_price_per_gb))
|
||||
|
||||
# 用户级默认免费额度覆盖
|
||||
user_default_override = None
|
||||
if hasattr(user, 'profile') and user.profile.default_free_traffic_gb_per_domain_override is not None:
|
||||
user_default_override = Decimal(str(user.profile.default_free_traffic_gb_per_domain_override))
|
||||
|
||||
# 系统全局默认
|
||||
sys_default_gb = Decimal(str(sys.default_free_traffic_gb_per_domain)) if sys and sys.default_free_traffic_gb_per_domain is not None else Decimal('0')
|
||||
|
||||
# 最终基础配额:优先 Plan,其次用户覆盖,再次系统默认
|
||||
base_quota_gb = Decimal('0')
|
||||
if included_gb is not None:
|
||||
base_quota_gb = Decimal(str(included_gb))
|
||||
elif user_default_override is not None:
|
||||
base_quota_gb = user_default_override
|
||||
else:
|
||||
base_quota_gb = sys_default_gb
|
||||
|
||||
# 本周期域名额外赠送
|
||||
extra_gb = Decimal(str(d.extra_free_traffic_gb_current_cycle or 0))
|
||||
|
||||
# 统计周期用量
|
||||
traffic_qs = DomainTrafficDaily.objects.filter(domain=d, day__gte=period_start, day__lte=period_end)
|
||||
total_bytes = sum(t.bytes for t in traffic_qs)
|
||||
used_gb = Decimal(str(total_bytes)) / Decimal('1073741824') # 1024^3
|
||||
used_gb = used_gb.quantize(Decimal('0.001'), rounding=ROUND_HALF_UP)
|
||||
|
||||
# 超量(不为负)
|
||||
over_gb = used_gb - (base_quota_gb + extra_gb)
|
||||
if over_gb < Decimal('0'):
|
||||
over_gb = Decimal('0')
|
||||
over_gb = over_gb.quantize(Decimal('0.001'), rounding=ROUND_HALF_UP)
|
||||
|
||||
# 费用累计
|
||||
if base_price > 0:
|
||||
amount_plan_total += base_price
|
||||
items.append({
|
||||
'domain': d,
|
||||
'description': '基础套餐费用',
|
||||
'quantity': Decimal('1'),
|
||||
'unit_price': base_price,
|
||||
'amount': base_price,
|
||||
})
|
||||
|
||||
if over_gb > 0 and overage_price > 0:
|
||||
over_amount = (over_gb * overage_price).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||
amount_overage_total += over_amount
|
||||
items.append({
|
||||
'domain': d,
|
||||
'description': '超量流量费用',
|
||||
'quantity': over_gb,
|
||||
'unit_price': overage_price,
|
||||
'amount': over_amount,
|
||||
})
|
||||
|
||||
amount_adjustment = Decimal('0.00')
|
||||
amount_total = (amount_plan_total + amount_overage_total + amount_adjustment).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"[DRY] 用户 {user.id}({user.username}) 套餐费:¥{amount_plan_total} 超量费:¥{amount_overage_total} 总计:¥{amount_total}"
|
||||
))
|
||||
continue
|
||||
|
||||
# 创建账单与明细
|
||||
invoice = Invoice.objects.create(
|
||||
user=user,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
amount_plan_total=amount_plan_total,
|
||||
amount_overage_total=amount_overage_total,
|
||||
amount_adjustment=amount_adjustment,
|
||||
amount_total=amount_total,
|
||||
status='unpaid',
|
||||
)
|
||||
|
||||
for it in items:
|
||||
InvoiceItem.objects.create(
|
||||
invoice=invoice,
|
||||
domain=it['domain'],
|
||||
description=it['description'],
|
||||
quantity=it['quantity'],
|
||||
unit_price=it['unit_price'],
|
||||
amount=it['amount'],
|
||||
)
|
||||
|
||||
total_invoices += 1
|
||||
total_amount += amount_total
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"已生成账单:Invoice#{invoice.id} 用户 {user.id}({user.username}) 总额:¥{amount_total}"
|
||||
))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"生成完成:共 {total_invoices} 个账单,合计金额 ¥{total_amount}"
|
||||
))
|
||||
55
billing/migrations/0001_initial.py
Normal file
55
billing/migrations/0001_initial.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# 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 = [
|
||||
('domains', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Invoice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('period_start', models.DateField()),
|
||||
('period_end', models.DateField()),
|
||||
('amount_plan_total', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('amount_overage_total', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('amount_adjustment', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('amount_total', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('status', models.CharField(choices=[('unpaid', '未支付'), ('paid', '已支付'), ('cancelled', '已取消')], default='unpaid', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('paid_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '账单',
|
||||
'verbose_name_plural': '账单',
|
||||
'ordering': ['-period_end'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InvoiceItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('description', models.CharField(max_length=200)),
|
||||
('quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12)),
|
||||
('unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('domain', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='domains.domain')),
|
||||
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='billing.invoice')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '账单项',
|
||||
'verbose_name_plural': '账单项',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
billing/migrations/__init__.py
Normal file
0
billing/migrations/__init__.py
Normal file
BIN
billing/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
BIN
billing/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
Binary file not shown.
BIN
billing/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
billing/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
47
billing/models.py
Normal file
47
billing/models.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Invoice(models.Model):
|
||||
STATUS_UNPAID = 'unpaid'
|
||||
STATUS_PAID = 'paid'
|
||||
STATUS_CANCELLED = 'cancelled'
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_UNPAID, '未支付'),
|
||||
(STATUS_PAID, '已支付'),
|
||||
(STATUS_CANCELLED, '已取消'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='invoices')
|
||||
period_start = models.DateField()
|
||||
period_end = models.DateField()
|
||||
amount_plan_total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
amount_overage_total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
amount_adjustment = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
amount_total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_UNPAID)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
paid_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '账单'
|
||||
verbose_name_plural = '账单'
|
||||
ordering = ['-period_end']
|
||||
|
||||
|
||||
class InvoiceItem(models.Model):
|
||||
invoice = models.ForeignKey(Invoice, related_name='items', on_delete=models.CASCADE)
|
||||
domain = models.ForeignKey('domains.Domain', on_delete=models.SET_NULL, null=True, blank=True)
|
||||
description = models.CharField(max_length=200)
|
||||
quantity = models.DecimalField(max_digits=12, decimal_places=3, default=0) # GB
|
||||
unit_price = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
amount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '账单项'
|
||||
verbose_name_plural = '账单项'
|
||||
|
||||
# Create your models here.
|
||||
72
billing/tests.py
Normal file
72
billing/tests.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
from decimal import Decimal
|
||||
from django.utils import timezone
|
||||
|
||||
from plans.models import Plan
|
||||
from domains.models import Domain, DomainTrafficDaily
|
||||
from billing.models import Invoice
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class BillingFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='u2', email='u2@example.com', password='p', is_staff=True)
|
||||
self.plan = Plan.objects.create(name='P', base_price_per_domain=Decimal('10.00'), included_traffic_gb_per_domain=1, overage_price_per_gb=Decimal('1.00'))
|
||||
today = timezone.now().date()
|
||||
month_start = today.replace(day=1)
|
||||
next_month = (month_start.replace(year=month_start.year + 1, month=1) if month_start.month == 12 else month_start.replace(month=month_start.month + 1))
|
||||
cycle_end = next_month - timezone.timedelta(days=1)
|
||||
self.domain = Domain.objects.create(user=self.user, name='ex2.com', status=Domain.STATUS_ACTIVE, current_plan=self.plan, current_cycle_start=month_start, current_cycle_end=cycle_end)
|
||||
DomainTrafficDaily.objects.create(domain=self.domain, day=month_start, bytes=2 * (1024 ** 3))
|
||||
|
||||
def test_generate_invoice_and_apply_policy_dry(self):
|
||||
call_command('generate_invoices')
|
||||
inv = Invoice.objects.filter(user=self.user).first()
|
||||
self.assertIsNotNone(inv)
|
||||
self.assertEqual(inv.status, Invoice.STATUS_UNPAID)
|
||||
call_command('apply_invoice_policies', dry_run=True)
|
||||
inv.refresh_from_db()
|
||||
self.assertEqual(inv.status, Invoice.STATUS_UNPAID)
|
||||
|
||||
def test_mark_paid_via_admin_view(self):
|
||||
call_command('generate_invoices')
|
||||
inv = Invoice.objects.filter(user=self.user).first()
|
||||
c = Client()
|
||||
c.login(username='u2', password='p')
|
||||
resp = c.post(reverse('admin_panel:billing_detail', kwargs={'invoice_id': inv.id}), {'action': 'mark_paid'})
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
inv.refresh_from_db()
|
||||
self.assertEqual(inv.status, Invoice.STATUS_PAID)
|
||||
|
||||
def test_epay_notify_marks_paid(self):
|
||||
call_command('generate_invoices')
|
||||
inv = Invoice.objects.filter(user=self.user).first()
|
||||
from core.models import SystemSettings
|
||||
s = SystemSettings.objects.order_by('id').first()
|
||||
if not s:
|
||||
s = SystemSettings.objects.create()
|
||||
s.epay_api_base_url = 'https://api.example.com'
|
||||
s.epay_pid = 'pid1'
|
||||
s.epay_key = 'k123'
|
||||
s.save()
|
||||
params = {
|
||||
'pid': s.epay_pid,
|
||||
'out_trade_no': f'INV{inv.id}',
|
||||
'money': str(inv.amount_total),
|
||||
'type': 'alipay',
|
||||
'trade_status': 'SUCCESS',
|
||||
'sign_type': 'MD5',
|
||||
}
|
||||
import hashlib
|
||||
src = '&'.join(f"{k}={params[k]}" for k in sorted(params) if k not in ['sign', 'sign_type']) + '&key=' + s.epay_key
|
||||
params['sign'] = hashlib.md5(src.encode('utf-8')).hexdigest().upper()
|
||||
c = Client()
|
||||
resp = c.get(reverse('billing:notify'), params)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
inv.refresh_from_db()
|
||||
self.assertEqual(inv.status, Invoice.STATUS_PAID)
|
||||
12
billing/urls.py
Normal file
12
billing/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.list_billing, name='list'),
|
||||
path('inv/<int:invoice_id>/', views.invoice_detail, name='detail'),
|
||||
path('inv/<int:invoice_id>/csv/', views.invoice_detail_csv, name='detail_csv'),
|
||||
path('inv/<int:invoice_id>/pay/', views.invoice_pay, name='pay'),
|
||||
path('payment/notify/', views.payment_notify, name='notify'),
|
||||
path('payment/return/', views.payment_return, name='return'),
|
||||
]
|
||||
184
billing/views.py
Normal file
184
billing/views.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from django.shortcuts import render
|
||||
import logging
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Sum
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils import timezone
|
||||
from django.contrib import messages
|
||||
|
||||
from .models import Invoice
|
||||
from domains.models import DomainTrafficDaily
|
||||
from decimal import Decimal
|
||||
from core.models import OperationLog
|
||||
from core.models import SystemSettings
|
||||
from django.http import HttpResponse
|
||||
import hashlib
|
||||
import urllib.parse
|
||||
|
||||
|
||||
@login_required
|
||||
def list_billing(request):
|
||||
status = request.GET.get('status')
|
||||
invoices_qs = Invoice.objects.filter(user=request.user)
|
||||
if status:
|
||||
invoices_qs = invoices_qs.filter(status=status)
|
||||
invoices = invoices_qs.order_by('-period_end')
|
||||
totals = invoices_qs.aggregate(total_amount=Sum('amount_total'))
|
||||
return render(request, 'billing/list.html', {
|
||||
'invoices': invoices,
|
||||
'total_amount': totals.get('total_amount') or 0,
|
||||
'status': status or '',
|
||||
'status_choices': getattr(Invoice, 'STATUS_CHOICES', []),
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def invoice_detail(request, invoice_id: int):
|
||||
invoice = get_object_or_404(Invoice, id=invoice_id)
|
||||
# 权限:仅本人或staff可查看
|
||||
if not (invoice.user_id == request.user.id or request.user.is_staff):
|
||||
return redirect('billing:list')
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.POST.get('action')
|
||||
if action == 'mark_paid' and invoice.status == Invoice.STATUS_UNPAID:
|
||||
invoice.status = Invoice.STATUS_PAID
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save(update_fields=['status', 'paid_at'])
|
||||
try:
|
||||
OperationLog.objects.create(
|
||||
actor=request.user,
|
||||
action='invoice_mark_paid',
|
||||
target=f"Invoice#{invoice.id}",
|
||||
detail=f"period={invoice.period_start}→{invoice.period_end}; amount_total={invoice.amount_total}"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('operation log write failed')
|
||||
messages.success(request, '账单已标记为已支付。')
|
||||
return redirect('billing:detail', invoice_id=invoice.id)
|
||||
|
||||
items = invoice.items.select_related('domain').all()
|
||||
plan_total = invoice.amount_plan_total
|
||||
overage_total = invoice.amount_overage_total
|
||||
adjustment = invoice.amount_adjustment
|
||||
amount_total = invoice.amount_total
|
||||
# 聚合账单周期内的域名用量(GB)
|
||||
traffic_by_domain = {}
|
||||
domain_stats = []
|
||||
period_start = invoice.period_start
|
||||
period_end = invoice.period_end
|
||||
for it in items:
|
||||
if it.domain_id and it.domain_id not in traffic_by_domain:
|
||||
bytes_sum = DomainTrafficDaily.objects.filter(domain_id=it.domain_id, day__gte=period_start, day__lte=period_end).aggregate(b=Sum('bytes'))['b'] or 0
|
||||
gb = round(Decimal(str(bytes_sum)) / Decimal(str(1024 ** 3)), 3)
|
||||
traffic_by_domain[it.domain_id] = gb
|
||||
domain_stats.append({'domain': it.domain, 'gb': gb})
|
||||
return render(request, 'billing/detail.html', {
|
||||
'invoice': invoice,
|
||||
'items': items,
|
||||
'plan_total': plan_total,
|
||||
'overage_total': overage_total,
|
||||
'adjustment': adjustment,
|
||||
'amount_total': amount_total,
|
||||
'traffic_by_domain': traffic_by_domain,
|
||||
'domain_stats': domain_stats,
|
||||
'period_start': period_start,
|
||||
'period_end': period_end,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def invoice_pay(request, invoice_id: int):
|
||||
invoice = get_object_or_404(Invoice, id=invoice_id, user=request.user)
|
||||
if invoice.status != Invoice.STATUS_UNPAID:
|
||||
return redirect('billing:detail', invoice_id=invoice.id)
|
||||
settings_obj = SystemSettings.objects.order_by('id').first()
|
||||
api = (settings_obj.epay_api_base_url or '').rstrip('/')
|
||||
pid = settings_obj.epay_pid or ''
|
||||
key = settings_obj.epay_key or ''
|
||||
if not (api and pid and key):
|
||||
messages.error(request, '支付未配置')
|
||||
return redirect('billing:detail', invoice_id=invoice.id)
|
||||
pay_type = request.GET.get('type') or 'alipay'
|
||||
params = {
|
||||
'pid': pid,
|
||||
'type': pay_type,
|
||||
'out_trade_no': f'INV{invoice.id}',
|
||||
'notify_url': request.build_absolute_uri(reverse('billing:notify')),
|
||||
'return_url': request.build_absolute_uri(reverse('billing:return')),
|
||||
'name': f'Invoice#{invoice.id}',
|
||||
'money': str(invoice.amount_total),
|
||||
'sitename': 'PyGoEdge',
|
||||
'sign_type': 'MD5',
|
||||
}
|
||||
sign_src = '&'.join(f"{k}={params[k]}" for k in sorted(params) if k not in ['sign', 'sign_type'] and params[k] != '') + '&key=' + key
|
||||
sign = hashlib.md5(sign_src.encode('utf-8')).hexdigest().upper()
|
||||
params['sign'] = sign
|
||||
url = api + '/submit.php?' + urllib.parse.urlencode(params)
|
||||
try:
|
||||
OperationLog.objects.create(actor=request.user, action='invoice_pay_create', target=f'Invoice#{invoice.id}', detail=url)
|
||||
except Exception:
|
||||
pass
|
||||
return redirect(url)
|
||||
|
||||
|
||||
def _verify_epay_sign(params: dict, key: str) -> bool:
|
||||
p = {k: v for k, v in params.items() if k not in ['sign', 'sign_type'] and v is not None and v != ''}
|
||||
src = '&'.join(f"{k}={p[k]}" for k in sorted(p)) + '&key=' + key
|
||||
calc = hashlib.md5(src.encode('utf-8')).hexdigest().upper()
|
||||
return calc == (params.get('sign') or '').upper()
|
||||
|
||||
|
||||
def payment_notify(request):
|
||||
settings_obj = SystemSettings.objects.order_by('id').first()
|
||||
key = settings_obj.epay_key or ''
|
||||
params = dict(request.GET.items())
|
||||
ok = (_verify_epay_sign(params, key) if key else False)
|
||||
out_trade_no = params.get('out_trade_no') or ''
|
||||
trade_status = params.get('trade_status') or ''
|
||||
money = params.get('money') or ''
|
||||
if ok and out_trade_no.startswith('INV') and trade_status:
|
||||
inv_id = int(out_trade_no.replace('INV', ''))
|
||||
inv = Invoice.objects.filter(id=inv_id).first()
|
||||
if inv and inv.status == Invoice.STATUS_UNPAID:
|
||||
if str(inv.amount_total) == str(money):
|
||||
inv.status = Invoice.STATUS_PAID
|
||||
inv.paid_at = timezone.now()
|
||||
inv.save(update_fields=['status', 'paid_at'])
|
||||
try:
|
||||
OperationLog.objects.create(actor=None, action='invoice_paid_notify', target=f'Invoice#{inv.id}', detail=out_trade_no)
|
||||
except Exception:
|
||||
pass
|
||||
return HttpResponse('SUCCESS')
|
||||
return HttpResponse('FAIL')
|
||||
|
||||
|
||||
@login_required
|
||||
def payment_return(request):
|
||||
messages.info(request, '支付流程已完成,如账单仍显示未支付,请稍后或刷新页面。')
|
||||
return redirect('billing:list')
|
||||
|
||||
|
||||
@login_required
|
||||
def invoice_detail_csv(request, invoice_id: int):
|
||||
invoice = get_object_or_404(Invoice, id=invoice_id)
|
||||
if not (invoice.user_id == request.user.id or request.user.is_staff):
|
||||
return redirect('billing:list')
|
||||
import csv
|
||||
from django.http import HttpResponse
|
||||
response = HttpResponse(content_type='text/csv; charset=utf-8')
|
||||
response['Content-Disposition'] = f'attachment; filename="invoice_{invoice.id}.csv"'
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(['Domain', 'Description', 'Quantity(GB)', 'Unit Price', 'Amount'])
|
||||
for it in invoice.items.select_related('domain').all():
|
||||
writer.writerow([
|
||||
it.domain.name if it.domain else '',
|
||||
it.description,
|
||||
str(it.quantity),
|
||||
str(it.unit_price),
|
||||
str(it.amount),
|
||||
])
|
||||
return response
|
||||
|
||||
# Create your views here.
|
||||
logger = logging.getLogger(__name__)
|
||||
Reference in New Issue
Block a user