诚实性修复: API限流Retry-After(wait=60s)+断言 / purge定时接线(04:00 cron+actor+测试) / Channel表单校验(ref+rules+validate) / README PG数字513

This commit is contained in:
seagull
2026-09-12 02:20:50 +08:00
parent b3f3095d53
commit 67540e8d30
40 changed files with 1805 additions and 31 deletions
@@ -0,0 +1,86 @@
"""P1-3 · 审计日志保留策略:purge_audit_logs。
`--days N` 保留窗口(默认 180);`--dry-run` 只报数不删;
不传 `--yes` 不删(防手滑);`--batch SIZE` 分批 delete;
`--export path.jsonl` 删除前先落 NDJSON 归档。
"""
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
help = "按保留窗口删除过期审计日志(默认保留 180 天)。"
def add_arguments(self, parser):
parser.add_argument("--days", type=int, default=180,
help="保留最近 N 天(默认 180)")
parser.add_argument("--dry-run", action="store_true",
help="只统计,不删除")
parser.add_argument("--yes", action="store_true",
help="确认执行删除(不传则不删)")
parser.add_argument("--batch", type=int, default=1000,
help="每批删除条数(默认 1000,避免长事务锁表)")
parser.add_argument("--export", type=str, default="",
help="删除前把待删记录导出为 NDJSON 到该路径")
def handle(self, *args, **options):
from datetime import timedelta
from django.utils import timezone
from apps.core.models import AuditLog
days = options["days"]
if days is None or days < 1:
raise CommandError("--days 必须 >= 1")
batch = options["batch"] or 1000
if batch < 1:
raise CommandError("--batch 必须 >= 1")
cutoff = timezone.now() - timedelta(days=days)
base_qs = AuditLog.objects.filter(created_at__lt=cutoff)
total = base_qs.count()
self.stdout.write(f"保留窗口:{days} 天(早于 {cutoff.isoformat()} 的 {total} 条待处理)")
export_path = options["export"]
if export_path and total:
import json
count = 0
with open(export_path, "w", encoding="utf-8") as fh:
for row in base_qs.order_by("id").iterator(chunk_size=batch):
fh.write(json.dumps({
"id": row.id,
"tenant_id": row.tenant_id,
"user_id": row.user_id,
"action": row.action,
"target_type": row.target_type,
"target_id": row.target_id,
"detail": row.detail,
"ip": str(row.ip) if row.ip else None,
"user_agent": row.user_agent,
"created_at": row.created_at.isoformat(),
}, ensure_ascii=False) + "\n")
count += 1
self.stdout.write(f"已归档 {count} 条 → {export_path}")
if options["dry_run"]:
self.stdout.write("dry-run:未删除任何记录。")
return f"dry-run: {total} matched"
if not options["yes"]:
self.stdout.write("未传 --yes:未删除任何记录(防手滑)。")
return f"needs --yes: {total} matched"
deleted = 0
while True:
ids = list(
AuditLog.objects.filter(created_at__lt=cutoff)
.order_by("id")
.values_list("id", flat=True)[:batch]
)
if not ids:
break
n, _ = AuditLog.objects.filter(pk__in=ids).delete()
deleted += n
self.stdout.write(self.style.SUCCESS(f"已删除 {deleted} 条过期审计日志。"))
return f"deleted: {deleted}"
@@ -21,7 +21,7 @@ from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.core.models import Org, Tenant
from apps.core.models import Org, Tenant, TenantMembership
from apps.catalog.models import Brand, Category, Product, Unit, UnitConversion
from apps.partner.models import Customer, Supplier
from apps.inventory.models import Warehouse
@@ -131,6 +131,12 @@ class Command(BaseCommand):
if created or not user.check_password(DEMO_PASSWORD):
user.set_password(DEMO_PASSWORD)
user.save()
# The demo token flow authenticates this account, and the membership
# permission requires an explicit relation for it.
TenantMembership.objects.get_or_create(
user=user, tenant=tenant,
defaults={"role": "owner", "is_active": True},
)
self.stdout.write(f" {'✓ 创建' if created else '· 复用'} 演示账号 {DEMO_USER}")
return user