From 02c82f16856d5a74f6d46b1028c1b7cbd4907276 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 11:17:45 +0000 Subject: [PATCH] Fix critical sales and backup restore regressions Co-authored-by: Xianist Lab --- inventory/templates/inventory/sale_form.html | 4 - .../templates/inventory/sale_item_form.html | 11 +- .../inventory/system/delete_backup.html | 37 ++++ .../inventory/system/maintenance.html | 52 +++++ .../inventory/system/store_settings.html | 39 ++++ .../inventory/system/system_info.html | 76 ++++++++ inventory/tests/test_sale_status.py | 27 +++ inventory/tests/test_sales_balance_payment.py | 14 ++ inventory/tests/test_views.py | 138 ++++++++++++++ inventory/urls.py | 2 + inventory/views/sales.py | 142 +++++++------- inventory/views/system/backup.py | 179 ++++++++++++------ 12 files changed, 587 insertions(+), 134 deletions(-) create mode 100644 inventory/templates/inventory/system/delete_backup.html create mode 100644 inventory/templates/inventory/system/maintenance.html create mode 100644 inventory/templates/inventory/system/store_settings.html create mode 100644 inventory/templates/inventory/system/system_info.html diff --git a/inventory/templates/inventory/sale_form.html b/inventory/templates/inventory/sale_form.html index 5675f91..4142516 100644 --- a/inventory/templates/inventory/sale_form.html +++ b/inventory/templates/inventory/sale_form.html @@ -1346,10 +1346,6 @@
{% if request.LANGUAGE_CODE == 'en' %}Payment Met {% if request.LANGUAGE_CODE == 'en' %}Balance{% else %}余额{% endif %} - diff --git a/inventory/templates/inventory/sale_item_form.html b/inventory/templates/inventory/sale_item_form.html index 6193151..f3c72c5 100644 --- a/inventory/templates/inventory/sale_item_form.html +++ b/inventory/templates/inventory/sale_item_form.html @@ -476,16 +476,19 @@

添加销售商品

- {% for item in sale.saleitems.all %} + {% for item in items %} {{ item.product.name }} {{ item.quantity }} {{ item.actual_price }} {{ item.subtotal }} - - - +
+ {% csrf_token %} + +
{% endfor %} diff --git a/inventory/templates/inventory/system/delete_backup.html b/inventory/templates/inventory/system/delete_backup.html new file mode 100644 index 0000000..68b1962 --- /dev/null +++ b/inventory/templates/inventory/system/delete_backup.html @@ -0,0 +1,37 @@ +{% extends 'inventory/base.html' %} + +{% block title %}删除备份 - {{ block.super }}{% endblock %} + +{% block content %} +
+
+
+
+

删除备份

+

确认删除系统备份

+
+
+
+
+ +
+
+
+
+
危险操作
+
+
+

确定要删除备份 {{ backup_name }} 吗?

+

此操作不可恢复。

+ +
+ {% csrf_token %} + + 取消 + +
+
+
+
+
+{% endblock %} diff --git a/inventory/templates/inventory/system/maintenance.html b/inventory/templates/inventory/system/maintenance.html new file mode 100644 index 0000000..8399bb2 --- /dev/null +++ b/inventory/templates/inventory/system/maintenance.html @@ -0,0 +1,52 @@ +{% extends 'inventory/base.html' %} + +{% block title %}系统维护 - {{ block.super }}{% endblock %} + +{% block content %} +
+ + +

系统维护

+ +
+
+
+
磁盘使用率:{{ disk_usage_percent }}%
+
+
+
+
+
内存使用率:{{ memory_usage_percent }}%
+
+
+
+
+
日志大小:{{ log_size_mb }} MB
+
+
+
+
+
活跃会话:{{ active_sessions }}
+
+
+
+ +
+
维护操作
+
+
+ {% csrf_token %} + + + +
+
+
+
+{% endblock %} diff --git a/inventory/templates/inventory/system/store_settings.html b/inventory/templates/inventory/system/store_settings.html new file mode 100644 index 0000000..b627f24 --- /dev/null +++ b/inventory/templates/inventory/system/store_settings.html @@ -0,0 +1,39 @@ +{% extends 'inventory/base.html' %} + +{% block title %}商店设置 - {{ block.super }}{% endblock %} + +{% block content %} +
+ + +

商店设置

+ +
+
+
+ {% csrf_token %} +
+ + +
+
+ + +
+
+ + +
+ 返回 + +
+
+
+
+{% endblock %} diff --git a/inventory/templates/inventory/system/system_info.html b/inventory/templates/inventory/system/system_info.html new file mode 100644 index 0000000..6fe06ec --- /dev/null +++ b/inventory/templates/inventory/system/system_info.html @@ -0,0 +1,76 @@ +{% extends 'inventory/base.html' %} + +{% block title %}系统信息 - {{ block.super }}{% endblock %} + +{% block content %} +
+ + +

系统信息

+ +
+
+
+
运行环境
+
+
+
操作系统
+
{{ system_info.os }} {{ system_info.os_version }}
+
Python
+
{{ system_info.python_version }}
+
Django
+
{{ system_info.django_version }}
+
主机名
+
{{ system_info.hostname }}
+
服务器时间
+
{{ system_info.server_time|date:"Y-m-d H:i:s" }}
+
+
+
+
+ +
+
+
资源与数据
+
+
+
CPU 核心
+
{{ system_info.cpu_count }}
+
内存总量
+
{{ system_info.memory_total }} GB
+
可用内存
+
{{ system_info.memory_available }} GB
+
磁盘总量
+
{{ system_info.disk_total }} GB
+
磁盘剩余
+
{{ system_info.disk_free }} GB
+
媒体文件
+
{{ media_size_mb }} MB
+
日志文件
+
{{ log_size_mb }} MB / {{ log_entries }} 行
+
+
+
+
+
+ +
+
数据库记录数
+
+
+
商品:{{ db_stats.product_count }}
+
分类:{{ db_stats.category_count }}
+
库存:{{ db_stats.inventory_count }}
+
销售:{{ db_stats.sale_count }}
+
会员:{{ db_stats.member_count }}
+
+
+
+
+{% endblock %} diff --git a/inventory/tests/test_sale_status.py b/inventory/tests/test_sale_status.py index 270e7a3..4e57b49 100644 --- a/inventory/tests/test_sale_status.py +++ b/inventory/tests/test_sale_status.py @@ -160,6 +160,33 @@ def test_delete_item_persists_recalculated_total(self): sale.refresh_from_db() self.assertEqual(sale.total_amount, Decimal('20.00')) # 删除后总额已落库 + def test_sale_detail_does_not_rewrite_historical_amounts(self): + sale = self._make_sale(status='COMPLETED') + sale.total_amount = Decimal('1.00') + sale.discount_amount = Decimal('0.00') + sale.final_amount = Decimal('1.00') + sale.save() + + response = self.client.get(reverse('sale_detail', args=[sale.id])) + + self.assertEqual(response.status_code, 200) + sale.refresh_from_db() + self.assertEqual(sale.total_amount, Decimal('1.00')) + self.assertEqual(sale.final_amount, Decimal('1.00')) + + def test_delete_item_requires_post(self): + sale = self._make_sale(status='DRAFT') + item = sale.items.get() + self.inventory.refresh_from_db() + before = self.inventory.quantity + + response = self.client.get(reverse('sale_item_delete', args=[sale.id, item.id])) + + self.assertRedirects(response, reverse('sale_detail', args=[sale.id])) + self.assertTrue(SaleItem.objects.filter(pk=item.pk).exists()) + self.inventory.refresh_from_db() + self.assertEqual(self.inventory.quantity, before) + def test_sale_complete_page_renders_for_draft_sale(self): sale = self._make_sale(status='DRAFT') diff --git a/inventory/tests/test_sales_balance_payment.py b/inventory/tests/test_sales_balance_payment.py index ab5edc8..557ebfd 100644 --- a/inventory/tests/test_sales_balance_payment.py +++ b/inventory/tests/test_sales_balance_payment.py @@ -97,3 +97,17 @@ def test_sale_create_balance_payment_rolls_back_when_balance_is_insufficient(sel self.assertEqual(self.member.balance, Decimal('5.00')) self.inventory.refresh_from_db() self.assertEqual(self.inventory.quantity, 10) + + def test_sale_create_rejects_unsupported_credit_payment(self): + response = self.client.post(reverse('sale_create'), self.sale_post_data('credit')) + + self.assertRedirects(response, reverse('sale_create')) + self.assertFalse(Sale.objects.exists()) + self.inventory.refresh_from_db() + self.assertEqual(self.inventory.quantity, 10) + + def test_sale_create_page_does_not_offer_credit_payment(self): + response = self.client.get(reverse('sale_create')) + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'data-payment="credit"') diff --git a/inventory/tests/test_views.py b/inventory/tests/test_views.py index 5ed657d..a1d92cf 100644 --- a/inventory/tests/test_views.py +++ b/inventory/tests/test_views.py @@ -1,6 +1,8 @@ import json import os +import shutil import tempfile +from unittest import mock from django.core import management from django.test import TestCase, Client @@ -321,3 +323,139 @@ def test_restore_backup_flushes_records_missing_from_snapshot(self): self.assertEqual(response.status_code, 302) self.assertEqual(response['Location'], reverse('system_settings')) self.assertFalse(Product.objects.filter(pk=product.pk).exists()) + + def test_restore_backup_accepts_template_confirmation_field(self): + backup_name = 'template_confirm' + backup_dir = os.path.join(self.backup_root, backup_name) + os.makedirs(backup_dir, exist_ok=True) + db_file = os.path.join(backup_dir, 'db.json') + + with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir): + management.call_command( + 'dumpdata', + '--exclude', + 'auth.permission', + '--exclude', + 'contenttypes', + '--exclude', + 'sessions.session', + '--indent', + '4', + '--output', + db_file, + verbosity=0, + ) + + with open(os.path.join(backup_dir, 'backup_info.json'), 'w', encoding='utf-8') as backup_info: + json.dump( + { + 'name': backup_name, + 'created_at': '2026-05-30T11:00:00', + 'created_by': self.user.username, + 'includes_media': False, + }, + backup_info, + ) + + with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir): + page = self.client.get(reverse('restore_backup', args=[backup_name])) + response = self.client.post( + reverse('restore_backup', args=[backup_name]), + {'confirm_restore': 'on'}, + ) + + self.assertEqual(page.status_code, 200) + self.assertContains(page, backup_name) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], reverse('system_settings')) + + def test_restore_backup_media_failure_rolls_back_database_and_media(self): + backup_name = 'media_failure' + backup_dir = os.path.join(self.backup_root, backup_name) + os.makedirs(backup_dir, exist_ok=True) + db_file = os.path.join(backup_dir, 'db.json') + + media_root = os.path.join(self.temp_parent.name, 'media') + os.makedirs(media_root, exist_ok=True) + with open(os.path.join(media_root, 'keep.txt'), 'w', encoding='utf-8') as media_file: + media_file.write('current media') + + category = Category.objects.create(name='备份内分类') + Product.objects.create( + barcode='snapshot-product', + name='备份内商品', + category=category, + price=Decimal('10.00'), + cost=Decimal('5.00'), + ) + + with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir, MEDIA_ROOT=media_root): + management.call_command( + 'dumpdata', + '--exclude', + 'auth.permission', + '--exclude', + 'contenttypes', + '--exclude', + 'sessions.session', + '--indent', + '4', + '--output', + db_file, + verbosity=0, + ) + + backup_media_dir = os.path.join(backup_dir, 'media') + os.makedirs(backup_media_dir, exist_ok=True) + with open(os.path.join(backup_media_dir, 'restored.txt'), 'w', encoding='utf-8') as media_file: + media_file.write('restored media') + + with open(os.path.join(backup_dir, 'backup_info.json'), 'w', encoding='utf-8') as backup_info: + json.dump( + { + 'name': backup_name, + 'created_at': '2026-05-30T11:00:00', + 'created_by': self.user.username, + 'includes_media': True, + }, + backup_info, + ) + + post_backup_product = Product.objects.create( + barcode='post-backup-product', + name='备份后商品', + category=category, + price=Decimal('20.00'), + cost=Decimal('8.00'), + ) + + real_move = shutil.move + + def fail_staged_media_move(src, dst, *args, **kwargs): + parent_name = os.path.basename(os.path.dirname(os.path.realpath(src))) + if parent_name.startswith('restore_media_') and os.path.realpath(dst) == os.path.realpath(media_root): + raise OSError('simulated media replacement failure') + return real_move(src, dst, *args, **kwargs) + + with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir, MEDIA_ROOT=media_root): + with mock.patch('inventory.views.system.backup.shutil.move', side_effect=fail_staged_media_move): + response = self.client.post( + reverse('restore_backup', args=[backup_name]), + {'confirm_restore': 'on', 'restore_media': 'on'}, + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(Product.objects.filter(pk=post_backup_product.pk).exists()) + self.assertTrue(os.path.exists(os.path.join(media_root, 'keep.txt'))) + self.assertFalse(os.path.exists(os.path.join(media_root, 'restored.txt'))) + + def test_delete_backup_confirmation_page_renders(self): + backup_name = 'delete_me' + backup_dir = os.path.join(self.backup_root, backup_name) + os.makedirs(backup_dir, exist_ok=True) + + with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir): + response = self.client.get(reverse('delete_backup', args=[backup_name])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, backup_name) diff --git a/inventory/urls.py b/inventory/urls.py index 7fbd678..e78133c 100644 --- a/inventory/urls.py +++ b/inventory/urls.py @@ -130,6 +130,8 @@ path('system/logs/download//', system_views.download_log_file, name='download_log_file'), path('system/logs/delete//', system_views.delete_log_file, name='delete_log_file'), path('system/settings/', system_views.system_settings, name='system_settings'), + path('system/info/', system_views.system_info, name='system_info'), + path('system/store-settings/', system_views.store_settings, name='store_settings'), path('system/maintenance/', system_views.system_maintenance, name='system_maintenance'), # 备份相关 - 使用重构后的系统视图 diff --git a/inventory/views/sales.py b/inventory/views/sales.py index 0fc02a4..0f2e2ef 100644 --- a/inventory/views/sales.py +++ b/inventory/views/sales.py @@ -19,6 +19,20 @@ from inventory.services import member_service from inventory.utils.query_utils import paginate_queryset + +SUPPORTED_PAYMENT_METHODS = {value for value, _label in Sale.PAYMENT_METHODS} + + +def normalize_payment_method(payment_method): + """Normalize legacy form values and reject unimplemented payment methods.""" + payment_method = payment_method or 'cash' + if payment_method == 'account': + payment_method = 'balance' + if payment_method not in SUPPORTED_PAYMENT_METHODS: + raise ValueError(f'不支持的支付方式: {payment_method}') + return payment_method + + @login_required def sale_list(request): """销售单列表视图""" @@ -79,32 +93,7 @@ def sale_detail(request, sale_id): """销售单详情视图""" sale = get_object_or_404(Sale, pk=sale_id) items = SaleItem.objects.filter(sale=sale).select_related('product') - - # 确保销售单金额与商品项总和一致 - items_total = sum(item.subtotal for item in items) - if items_total > 0 and (sale.total_amount == 0 or abs(sale.total_amount - items_total) > 1): - print(f"警告: 销售单金额({sale.total_amount})与商品项总和({items_total})不一致,正在修复") - # 更新销售单金额 - discount_rate = Decimal('1.0') - if sale.member and sale.member.level and sale.member.level.discount: - try: - discount_rate = Decimal(str(sale.member.level.discount)) - except: - discount_rate = Decimal('1.0') - - discount_amount = items_total * (Decimal('1.0') - discount_rate) - final_amount = items_total - discount_amount - - # 使用原始SQL直接更新数据库 - with connection.cursor() as cursor: - cursor.execute( - "UPDATE inventory_sale SET total_amount = %s, discount_amount = %s, final_amount = %s WHERE id = %s", - [items_total, discount_amount, final_amount, sale.id] - ) - - # 重新加载销售单数据 - sale = get_object_or_404(Sale, pk=sale_id) - + context = { 'sale': sale, 'items': items, @@ -331,11 +320,14 @@ def sale_create(request): # 最终安全检查,确保总金额大于0 if total_amount <= 0 and valid_products_data: - print("警告:计算的总金额仍然为0或负数,使用固定价格作为最后的保障") - # 使用855.33作为固定价格,这只是一个保底措施 - total_amount = Decimal('855.33') - discount_amount = Decimal('0.00') - final_amount = total_amount + messages.error(request, '销售单创建失败,销售金额必须大于0。') + return redirect('sale_create') + + try: + payment_method = normalize_payment_method(request.POST.get('payment_method', 'cash')) + except ValueError as e: + messages.error(request, str(e)) + return redirect('sale_create') form = SaleForm(request.POST) if form.is_valid(): @@ -358,9 +350,6 @@ def sale_create(request): pass # 设置支付方式。旧前端曾提交 account,后端统一按账户余额处理。 - payment_method = request.POST.get('payment_method', 'cash') - if payment_method == 'account': - payment_method = 'balance' sale.payment_method = payment_method # 收银台是一次性下单并结算,直接标记为已完成 @@ -646,9 +635,9 @@ def sale_complete(request, sale_id): sale.final_amount = sale.total_amount - sale.discount_amount sale.points_earned = int(sale.final_amount) - payment_method = request.POST.get('payment_method') or sale.payment_method - if payment_method == 'account': - payment_method = 'balance' + payment_method = normalize_payment_method( + request.POST.get('payment_method') or sale.payment_method + ) sale.payment_method = payment_method balance_amount = Decimal('0.00') @@ -781,41 +770,50 @@ def sale_cancel(request, sale_id): @login_required def sale_delete_item(request, sale_id, item_id): """删除销售单商品视图""" - sale = get_object_or_404(Sale, id=sale_id) - item = get_object_or_404(SaleItem, id=item_id, sale=sale) - - # 检查销售单状态 - if sale.status != 'DRAFT': - messages.error(request, '只有未完成的销售单可以修改商品') - return redirect('sale_detail', sale_id=sale.id) - - # 恢复库存 - inventory = Inventory.objects.get(product=item.product) - inventory.quantity += item.quantity - inventory.save() - - # 创建入库交易记录 - InventoryTransaction.objects.create( - product=item.product, - transaction_type='IN', - quantity=item.quantity, - operator=request.user, - notes=f'从销售单 #{sale.id} 中删除商品,恢复库存' - ) - - # 记录操作日志 - OperationLog.objects.create( - operator=request.user, - operation_type='SALE', - details=f'从销售单 #{sale.id} 中删除商品 {item.product.name}', - related_object_id=sale.id, - related_content_type=ContentType.objects.get_for_model(Sale) - ) - - # 删除商品并更新销售单总额 - item.delete() - sale.update_total_amount() - sale.save() + if request.method != 'POST': + messages.error(request, '删除销售商品需要提交确认。') + return redirect('sale_detail', sale_id=sale_id) + + with transaction.atomic(): + sale = get_object_or_404(Sale.objects.select_for_update(), id=sale_id) + item = get_object_or_404( + SaleItem.objects.select_for_update().select_related('product'), + id=item_id, + sale=sale, + ) + + # 检查销售单状态 + if sale.status != 'DRAFT': + messages.error(request, '只有未完成的销售单可以修改商品') + return redirect('sale_detail', sale_id=sale.id) + + # 恢复库存 + inventory = Inventory.objects.select_for_update().get(product=item.product) + inventory.quantity += item.quantity + inventory.save() + + # 创建入库交易记录 + InventoryTransaction.objects.create( + product=item.product, + transaction_type='IN', + quantity=item.quantity, + operator=request.user, + notes=f'从销售单 #{sale.id} 中删除商品,恢复库存' + ) + + # 记录操作日志 + OperationLog.objects.create( + operator=request.user, + operation_type='SALE', + details=f'从销售单 #{sale.id} 中删除商品 {item.product.name}', + related_object_id=sale.id, + related_content_type=ContentType.objects.get_for_model(Sale) + ) + + # 删除商品并更新销售单总额 + item.delete() + sale.update_total_amount() + sale.save() messages.success(request, '商品已从销售单中删除') return redirect('sale_item_create', sale_id=sale.id) diff --git a/inventory/views/system/backup.py b/inventory/views/system/backup.py index 39a2423..61ffd52 100644 --- a/inventory/views/system/backup.py +++ b/inventory/views/system/backup.py @@ -20,6 +20,7 @@ import logging import re import zipfile +import tempfile from datetime import datetime from inventory.permissions.decorators import permission_required @@ -64,6 +65,28 @@ def get_dir_size_display(dir_path): else: return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + +def build_backup_context(backup_name, backup_info, backup_dir): + """Build the template contract used by backup restore/delete pages.""" + created_at = backup_info.get('created_at') + try: + created_at = datetime.fromisoformat(created_at) if created_at else None + except (TypeError, ValueError): + created_at = None + + return { + 'backup': { + 'name': backup_name, + 'created_at': created_at, + 'created_by': backup_info.get('created_by', '未知'), + 'size': get_dir_size_display(backup_dir), + 'includes_media': backup_info.get('includes_media', False), + 'description': backup_info.get('description', ''), + }, + 'backup_name': backup_name, + 'backup_info': backup_info, + } + @login_required @permission_required('inventory.can_manage_backup') def backup_list(request): @@ -212,82 +235,130 @@ def restore_backup(request, backup_name): if request.method == 'POST': # 确认恢复 - confirmed = request.POST.get('confirm') == 'on' + confirmed = ( + request.POST.get('confirm') == 'on' or + request.POST.get('confirm_restore') == 'on' + ) if not confirmed: messages.error(request, "请确认您要恢复备份") - return render(request, 'inventory/system/restore_backup.html', { - 'backup_name': backup_name, - 'backup_info': backup_info - }) + return render( + request, + 'inventory/system/restore_backup.html', + build_backup_context(backup_name, backup_info, backup_dir), + ) + staged_media_parent = None + old_media_parent = None + old_media_root = None + media_swapped = False try: # 恢复数据库 db_file = os.path.join(backup_dir, 'db.json') if not os.path.exists(db_file): messages.error(request, f"备份文件 {db_file} 不存在") return redirect('backup_list') - - # 先清空数据库再加载快照;loaddata 只会 upsert,不能删除备份后新增的数据。 - with transaction.atomic(): - management.call_command('flush', '--noinput', verbosity=0) - management.call_command('loaddata', db_file, verbosity=0) - + # 恢复媒体文件 restore_media = request.POST.get('restore_media') == 'on' + staged_media_root = None if restore_media and backup_info.get('includes_media', False): media_dir = os.path.join(backup_dir, 'media') - if os.path.exists(media_dir): - # 清空现有媒体目录 - if os.path.exists(settings.MEDIA_ROOT): - for item in os.listdir(settings.MEDIA_ROOT): - item_path = os.path.join(settings.MEDIA_ROOT, item) - if os.path.isdir(item_path): - shutil.rmtree(item_path) - else: - os.remove(item_path) - - # 复制备份中的媒体文件 - for item in os.listdir(media_dir): - src_path = os.path.join(media_dir, item) - dst_path = os.path.join(settings.MEDIA_ROOT, item) - if os.path.isdir(src_path): - if os.path.exists(dst_path): - shutil.rmtree(dst_path) - shutil.copytree(src_path, dst_path) - else: - if os.path.exists(dst_path): - os.remove(dst_path) - shutil.copy2(src_path, dst_path) - - # 恢复快照后,执行恢复的用户可能已不存在于当前数据库。 - restored_user = get_user_model().objects.filter(pk=request.user.pk).first() - if restored_user: - LogEntry.objects.create( - user=restored_user, - action_flag=2, # 修改 - content_type_id=None, # 自定义日志,无关联内容类型(id=0 会违反外键约束) - object_id=backup_name, - object_repr=f'恢复备份: {backup_name}', - change_message=f'恢复了系统备份 {backup_name}' + (' 包含媒体文件' if restore_media else '') + if not os.path.exists(media_dir): + raise FileNotFoundError(f"备份媒体目录 {media_dir} 不存在") + + staged_media_parent = tempfile.mkdtemp( + prefix='restore_media_', + dir=settings.TEMP_DIR, ) - else: - logger.warning("恢复备份后执行用户不存在,跳过管理日志记录: %s", backup_name) + staged_media_root = os.path.join(staged_media_parent, 'media') + shutil.copytree(media_dir, staged_media_root) + + # 先清空数据库再加载快照;loaddata 只会 upsert,不能删除备份后新增的数据。 + with transaction.atomic(): + management.call_command('flush', '--noinput', verbosity=0) + management.call_command('loaddata', db_file, verbosity=0) + + if staged_media_root: + media_root = settings.MEDIA_ROOT + os.makedirs(os.path.dirname(media_root), exist_ok=True) + old_media_parent = tempfile.mkdtemp( + prefix='old_media_', + dir=settings.TEMP_DIR, + ) + old_media_root = os.path.join(old_media_parent, 'media') + + if os.path.exists(media_root): + shutil.move(media_root, old_media_root) + + try: + shutil.move(staged_media_root, media_root) + except Exception: + if os.path.exists(media_root): + if os.path.isdir(media_root): + shutil.rmtree(media_root) + else: + os.remove(media_root) + if old_media_root and os.path.exists(old_media_root): + shutil.move(old_media_root, media_root) + raise + + media_swapped = True + + # 恢复快照后,执行恢复的用户可能已不存在于当前数据库。 + restored_user = get_user_model().objects.filter(pk=request.user.pk).first() + if restored_user: + LogEntry.objects.create( + user=restored_user, + action_flag=2, # 修改 + content_type_id=None, # 自定义日志,无关联内容类型(id=0 会违反外键约束) + object_id=backup_name, + object_repr=f'恢复备份: {backup_name}', + change_message=f'恢复了系统备份 {backup_name}' + (' 包含媒体文件' if restore_media else '') + ) + else: + logger.warning("恢复备份后执行用户不存在,跳过管理日志记录: %s", backup_name) + + if old_media_parent and os.path.exists(old_media_parent): + shutil.rmtree(old_media_parent) messages.success(request, f"成功恢复备份: {backup_name}") return redirect('system_settings') except Exception as e: + if media_swapped: + media_root = settings.MEDIA_ROOT + if os.path.exists(media_root): + if os.path.isdir(media_root): + shutil.rmtree(media_root) + else: + os.remove(media_root) + if old_media_root and os.path.exists(old_media_root): + try: + shutil.move(old_media_root, media_root) + except Exception as restore_error: + logger.critical( + "恢复备份失败后无法还原原媒体目录,旧媒体保留在 %s: %s", + old_media_root, + restore_error, + ) + old_media_parent = None messages.error(request, f"恢复备份失败: {str(e)}") logger.error(f"恢复备份失败: {str(e)}") - return render(request, 'inventory/system/restore_backup.html', { - 'backup_name': backup_name, - 'backup_info': backup_info - }) + return render( + request, + 'inventory/system/restore_backup.html', + build_backup_context(backup_name, backup_info, backup_dir), + ) + finally: + for temp_dir in (staged_media_parent, old_media_parent): + if temp_dir and os.path.exists(temp_dir): + shutil.rmtree(temp_dir) - return render(request, 'inventory/system/restore_backup.html', { - 'backup_name': backup_name, - 'backup_info': backup_info - }) + return render( + request, + 'inventory/system/restore_backup.html', + build_backup_context(backup_name, backup_info, backup_dir), + ) @login_required @permission_required('inventory.can_manage_backup')