From 99a442e883ca594c096ca2cde5686e9f5f5eb2be Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 19 Jun 2026 04:18:30 +0800 Subject: [PATCH] =?UTF-8?q?perf(core):=20Wave=202=20part1=20=E2=80=94=20?= =?UTF-8?q?=E5=A4=8D=E5=90=88=E7=B4=A2=E5=BC=95(=E5=8F=AA=E7=94=9F?= =?UTF-8?q?=E6=88=90=E4=B8=8Dapply)=20+=20=E6=B6=88=E6=81=AF=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=20N+1=20=E6=B2=BB=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 索引(只 makemigrations 看 SQL,远程 MySQL apply 留用户低峰 / rule 3): - Asset (team,category,-created_at)+(team,asset_type)、CreditLedger (team,-created_at)+ (team,ledger_type,created_at)、Project (team,-updated_at)、AITask (team,-created_at)、 Product (team,-created_at);迁移 assets0005/billing0002/projects0004/ai0008/products0002 消息中心 N+1(ops/views.py): - type_counts 原 6 次 count(每请求)→ 一次 aggregate(Count(filter=...)) 条件聚合 - ensure_team_notifications 项目花费原逐项目 aggregate(N+1)→ 一次 values('project').annotate(Sum) 验证: apps.ops 测试 3/3 OK;全量 6fail+1err 均为预存 image provider 路由失败(stash 跑 baseline 复现)= 零新增回归;索引迁移在 sqlite 测试库成功 apply Co-Authored-By: Claude Opus 4.8 --- ...008_aitask_ai_aitask_team_id_570138_idx.py | 21 ++++++++++ core/backend/apps/ai/models.py | 2 + ...assets_asse_team_id_4b1b57_idx_and_more.py | 25 +++++++++++ core/backend/apps/assets/models.py | 7 ++++ ...billing_cre_team_id_e0f18f_idx_and_more.py | 30 ++++++++++++++ core/backend/apps/billing/models.py | 4 +- core/backend/apps/ops/views.py | 41 +++++++++++++------ ..._product_products_pr_team_id_ebc2f5_idx.py | 21 ++++++++++ core/backend/apps/products/models.py | 2 + ..._project_projects_pr_team_id_e667f8_idx.py | 21 ++++++++++ core/backend/apps/projects/models.py | 2 + docs/todo/还原性能落地-进度.md | 20 ++++++++- 12 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 core/backend/apps/ai/migrations/0008_aitask_ai_aitask_team_id_570138_idx.py create mode 100644 core/backend/apps/assets/migrations/0005_asset_assets_asse_team_id_4b1b57_idx_and_more.py create mode 100644 core/backend/apps/billing/migrations/0002_remove_creditledger_billing_cre_team_id_e0f18f_idx_and_more.py create mode 100644 core/backend/apps/products/migrations/0002_product_products_pr_team_id_ebc2f5_idx.py create mode 100644 core/backend/apps/projects/migrations/0004_project_projects_pr_team_id_e667f8_idx.py diff --git a/core/backend/apps/ai/migrations/0008_aitask_ai_aitask_team_id_570138_idx.py b/core/backend/apps/ai/migrations/0008_aitask_ai_aitask_team_id_570138_idx.py new file mode 100644 index 0000000..0c00d33 --- /dev/null +++ b/core/backend/apps/ai/migrations/0008_aitask_ai_aitask_team_id_570138_idx.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.15 on 2026-06-18 20:13 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_loginsession_userpreference'), + ('ai', '0007_switch_image_to_yunqi'), + ('projects', '0003_scriptsegment_dialogue'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name='aitask', + index=models.Index(fields=['team', '-created_at'], name='ai_aitask_team_id_570138_idx'), + ), + ] diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py index e5a5291..65ab225 100644 --- a/core/backend/apps/ai/models.py +++ b/core/backend/apps/ai/models.py @@ -100,6 +100,8 @@ class AITask(TeamOwnedModel): models.Index(fields=["team", "status"]), models.Index(fields=["project", "task_type"]), models.Index(fields=["provider_task_id"]), + # 任务历史默认按团队 + 创建时间倒序(AI 工具页 / asset-factory) + models.Index(fields=["team", "-created_at"]), ] def __str__(self) -> str: diff --git a/core/backend/apps/assets/migrations/0005_asset_assets_asse_team_id_4b1b57_idx_and_more.py b/core/backend/apps/assets/migrations/0005_asset_assets_asse_team_id_4b1b57_idx_and_more.py new file mode 100644 index 0000000..9953771 --- /dev/null +++ b/core/backend/apps/assets/migrations/0005_asset_assets_asse_team_id_4b1b57_idx_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.15 on 2026-06-18 20:13 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_loginsession_userpreference'), + ('ai', '0008_aitask_ai_aitask_team_id_570138_idx'), + ('assets', '0004_asset_review_db_defaults'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name='asset', + index=models.Index(fields=['team', 'category', '-created_at'], name='assets_asse_team_id_4b1b57_idx'), + ), + migrations.AddIndex( + model_name='asset', + index=models.Index(fields=['team', 'asset_type'], name='assets_asse_team_id_51a9e4_idx'), + ), + ] diff --git a/core/backend/apps/assets/models.py b/core/backend/apps/assets/models.py index 3004495..25e4f96 100644 --- a/core/backend/apps/assets/models.py +++ b/core/backend/apps/assets/models.py @@ -45,6 +45,13 @@ class Asset(TeamOwnedModel): review_remote_id = models.CharField(max_length=128, blank=True, db_default="") review_error = models.TextField(blank=True, null=True, default="") # TEXT 不能有 DB 默认值(MySQL 1101),改用可空容忍「漏带字段」的插入 + class Meta: + indexes = [ + # 资产库按团队 + 分类倒序时间翻页(products 详情素材趴 / 资产库) + models.Index(fields=["team", "category", "-created_at"]), + models.Index(fields=["team", "asset_type"]), + ] + def __str__(self) -> str: return self.name diff --git a/core/backend/apps/billing/migrations/0002_remove_creditledger_billing_cre_team_id_e0f18f_idx_and_more.py b/core/backend/apps/billing/migrations/0002_remove_creditledger_billing_cre_team_id_e0f18f_idx_and_more.py new file mode 100644 index 0000000..d5a26b0 --- /dev/null +++ b/core/backend/apps/billing/migrations/0002_remove_creditledger_billing_cre_team_id_e0f18f_idx_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.15 on 2026-06-18 20:13 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_loginsession_userpreference'), + ('ai', '0008_aitask_ai_aitask_team_id_570138_idx'), + ('billing', '0001_initial'), + ('projects', '0003_scriptsegment_dialogue'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveIndex( + model_name='creditledger', + name='billing_cre_team_id_e0f18f_idx', + ), + migrations.AddIndex( + model_name='creditledger', + index=models.Index(fields=['team', '-created_at'], name='billing_cre_team_id_c88290_idx'), + ), + migrations.AddIndex( + model_name='creditledger', + index=models.Index(fields=['team', 'ledger_type', 'created_at'], name='billing_cre_team_id_ffdcfe_idx'), + ), + ] diff --git a/core/backend/apps/billing/models.py b/core/backend/apps/billing/models.py index 8b566f0..1461f91 100644 --- a/core/backend/apps/billing/models.py +++ b/core/backend/apps/billing/models.py @@ -40,7 +40,9 @@ class CreditLedger(TimeStampedModel): class Meta: indexes = [ - models.Index(fields=["team", "ledger_type"]), + # 团队流水倒序翻页 + 按类型过滤(账户消费页 / 消息中心 charge 通知) + models.Index(fields=["team", "-created_at"]), + models.Index(fields=["team", "ledger_type", "created_at"]), models.Index(fields=["project", "task"]), ] diff --git a/core/backend/apps/ops/views.py b/core/backend/apps/ops/views.py index dabea2a..57fe184 100644 --- a/core/backend/apps/ops/views.py +++ b/core/backend/apps/ops/views.py @@ -1,7 +1,7 @@ from datetime import timedelta from django.core.cache import cache -from django.db.models import Q, Sum +from django.db.models import Count, Q, Sum from django.utils import timezone from rest_framework import status from rest_framework.decorators import action @@ -107,12 +107,20 @@ def ensure_team_notifications(team, user): metadata={"timeline": [[_step_time(timezone.now()), "团队接入 AirShelf,真实消息中心已启用"]]}, ) - for project in Project.objects.filter(team=team).select_related("product", "created_by").order_by("-updated_at")[:5]: + recent_projects = list( + Project.objects.filter(team=team).select_related("product", "created_by").order_by("-updated_at")[:5] + ) + # 5 个项目的累计扣费一次聚合(原先每个项目各跑一次 aggregate = N+1) + spend_by_project = dict( + CreditLedger.objects.filter(project__in=recent_projects, ledger_type=CreditLedger.Type.CHARGE) + .values("project") + .annotate(total=Sum("amount")) + .values_list("project", "total") + ) + for project in recent_projects: product_title = project.product.title if project.product_id else "未绑定商品" # 项目累计花费 = 该项目所有 AI 扣费流水之和(脚本/图片/视频/导出),无扣费则显示「-」 - project_spend = project.credit_ledgers.filter( - ledger_type=CreditLedger.Type.CHARGE - ).aggregate(total=Sum("amount"))["total"] + project_spend = spend_by_project.get(project.id) project_cost = f"¥{project_spend:.2f}" if project_spend else "-" create_once( f"project:{project.id}:status:{project.status}:{project.current_stage}", @@ -280,15 +288,24 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet): if isinstance(data, dict): # 分类 chip 计数取绝对总数(忽略当前 tab/搜索),与设计稿一致 base = self._recipient_scope() - unread_count = base.filter(is_read=False).count() + # 原先 6 次独立 count(每次 list 请求都跑)合成一次条件聚合,接口少 5 个 DB 往返 + counts = base.aggregate( + all=Count("id"), + unread=Count("id", filter=Q(is_read=False)), + task=Count("id", filter=Q(notification_type="task")), + team=Count("id", filter=Q(notification_type="team")), + billing=Count("id", filter=Q(notification_type="billing")), + system=Count("id", filter=Q(notification_type="system")), + ) + unread_count = counts["unread"] data["unread_count"] = unread_count data["type_counts"] = { - "all": base.count(), - "unread": unread_count, - "task": base.filter(notification_type="task").count(), - "team": base.filter(notification_type="team").count(), - "billing": base.filter(notification_type="billing").count(), - "system": base.filter(notification_type="system").count(), + "all": counts["all"], + "unread": counts["unread"], + "task": counts["task"], + "team": counts["team"], + "billing": counts["billing"], + "system": counts["system"], } return response diff --git a/core/backend/apps/products/migrations/0002_product_products_pr_team_id_ebc2f5_idx.py b/core/backend/apps/products/migrations/0002_product_products_pr_team_id_ebc2f5_idx.py new file mode 100644 index 0000000..c03ec4f --- /dev/null +++ b/core/backend/apps/products/migrations/0002_product_products_pr_team_id_ebc2f5_idx.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.15 on 2026-06-18 20:13 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_loginsession_userpreference'), + ('assets', '0005_asset_assets_asse_team_id_4b1b57_idx_and_more'), + ('products', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name='product', + index=models.Index(fields=['team', '-created_at'], name='products_pr_team_id_ebc2f5_idx'), + ), + ] diff --git a/core/backend/apps/products/models.py b/core/backend/apps/products/models.py index df6063f..5334377 100644 --- a/core/backend/apps/products/models.py +++ b/core/backend/apps/products/models.py @@ -27,6 +27,8 @@ class Product(TeamOwnedModel): indexes = [ models.Index(fields=["team", "status"]), models.Index(fields=["team", "category"]), + # 商品库默认按团队 + 创建时间倒序翻页 + models.Index(fields=["team", "-created_at"]), ] def __str__(self) -> str: diff --git a/core/backend/apps/projects/migrations/0004_project_projects_pr_team_id_e667f8_idx.py b/core/backend/apps/projects/migrations/0004_project_projects_pr_team_id_e667f8_idx.py new file mode 100644 index 0000000..886ca4c --- /dev/null +++ b/core/backend/apps/projects/migrations/0004_project_projects_pr_team_id_e667f8_idx.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.15 on 2026-06-18 20:13 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_loginsession_userpreference'), + ('products', '0002_product_products_pr_team_id_ebc2f5_idx'), + ('projects', '0003_scriptsegment_dialogue'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['team', '-updated_at'], name='projects_pr_team_id_e667f8_idx'), + ), + ] diff --git a/core/backend/apps/projects/models.py b/core/backend/apps/projects/models.py index 4a67027..877c7bf 100644 --- a/core/backend/apps/projects/models.py +++ b/core/backend/apps/projects/models.py @@ -26,6 +26,8 @@ class Project(TeamOwnedModel): indexes = [ models.Index(fields=["team", "status"]), models.Index(fields=["team", "current_stage"]), + # 项目列表默认按团队 + 更新时间倒序 + models.Index(fields=["team", "-updated_at"]), ] def __str__(self) -> str: diff --git a/docs/todo/还原性能落地-进度.md b/docs/todo/还原性能落地-进度.md index 0f13362..89234b4 100644 --- a/docs/todo/还原性能落地-进度.md +++ b/docs/todo/还原性能落地-进度.md @@ -29,7 +29,25 @@ --- -## Wave 1 · 系统性 CSS/组件 (S1-S9) — ⏳ 进行中 +## Wave 2 · 后端查询治理 — ⏳ 进行中 + +### part 1 — ✅ 完成 (2026-06-19) + +| # | 项 | 状态 | 改动 / 证据 | +|---|---|---|---| +| 索引 | 复合索引(只 makemigrations 看 SQL,**未 apply 远程库**) | ✅ | 5 模型补复合索引:Asset `(team,category,-created_at)`+`(team,asset_type)`、CreditLedger `(team,-created_at)`+`(team,ledger_type,created_at)`(替原 `(team,ledger_type)`)、Project `(team,-updated_at)`、AITask `(team,-created_at)`、Product `(team,-created_at)`。生成迁移 assets/0005·billing/0002·projects/0004·ai/0008·products/0002,`sqlmigrate` 看到的都是干净 `CREATE INDEX`(+billing 一条 DROP 替换)。**远程 MySQL apply 留给用户低峰**(rule 3)。修了一处自摆乌龙:索引误加到 AssetFile,已挪回真正的 Asset 类。 | +| 消息 N+1 | type_counts + 项目花费聚合 | ✅ | `ops/views.py` `list()` 的 `type_counts`:原 6 次独立 `count()`(**每请求都跑**)→ 一次 `aggregate(Count(filter=...))` 条件聚合,少 5 个 DB 往返。`ensure_team_notifications` 首跑:5 个项目花费原**逐项目 aggregate**(N+1)→ 一次 `values('project').annotate(Sum)` 预聚合。 | + +**part 1 验证:** `python manage.py test apps.ops` → **3/3 OK**;全量 39 测试 6 fail+1 error **全部是预存** image_edit/provider 路由失败(stash 我的改动跑 baseline 42c3c04 复现同样失败)→ **零新增回归**(rule 11 ✅);索引迁移在 sqlite 测试库成功 apply(证迁移有效)。 + +### part 2 — ⏳ 待做(下一拍,起 celery + 浏览器 E2E) +- **poll-reviews 火山同步 HTTP 挪 Celery**(projects/views.py + assets/review.py;端点只读 DB review_status)——中风险,改审核轮询流,需 celery 起 + e2e。 +- **写 action 瘦身**(projects 写 action 不回吐全量 ProjectSerializer;前端 `action()` 补 liteRefresh、refreshProjectDetail 仅 pipeline 页)——**最高风险**,贴近 pipeline 数据流(rule 3 敏感),需浏览器走查管线 refresh 不回归,专一拍做。 +- **charge 通知 bulk_create**:暂缓 —— 仅后台 Celery 跑(非请求阻塞),且 `create_once` 创建+补字段二态耦合,裸批量改风险高 / 收益小,留作已知项。 + +--- + +## Wave 1 · 系统性 CSS/组件 (S1-S9) — ✅ 完成 > 为护住共享 CSS,拆两段做:**part 1 = 浮层/反馈(S3/S4/S9)**,part 2 = token/布局(S1/S2/S6/S7/S8)。