perf(core): Wave 2 part1 — 复合索引(只生成不apply) + 消息中心 N+1 治理

索引(只 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 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 04:18:30 +08:00
co-authored by Claude Opus 4.8
parent 42c3c046ea
commit 99a442e883
12 changed files with 182 additions and 14 deletions
@@ -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'),
),
]
+2
View File
@@ -100,6 +100,8 @@ class AITask(TeamOwnedModel):
models.Index(fields=["team", "status"]), models.Index(fields=["team", "status"]),
models.Index(fields=["project", "task_type"]), models.Index(fields=["project", "task_type"]),
models.Index(fields=["provider_task_id"]), models.Index(fields=["provider_task_id"]),
# 任务历史默认按团队 + 创建时间倒序(AI 工具页 / asset-factory)
models.Index(fields=["team", "-created_at"]),
] ]
def __str__(self) -> str: def __str__(self) -> str:
@@ -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'),
),
]
+7
View File
@@ -45,6 +45,13 @@ class Asset(TeamOwnedModel):
review_remote_id = models.CharField(max_length=128, blank=True, db_default="") 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),改用可空容忍「漏带字段」的插入 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: def __str__(self) -> str:
return self.name return self.name
@@ -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'),
),
]
+3 -1
View File
@@ -40,7 +40,9 @@ class CreditLedger(TimeStampedModel):
class Meta: class Meta:
indexes = [ 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"]), models.Index(fields=["project", "task"]),
] ]
+29 -12
View File
@@ -1,7 +1,7 @@
from datetime import timedelta from datetime import timedelta
from django.core.cache import cache 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 django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
@@ -107,12 +107,20 @@ def ensure_team_notifications(team, user):
metadata={"timeline": [[_step_time(timezone.now()), "团队接入 AirShelf,真实消息中心已启用"]]}, 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 "未绑定商品" product_title = project.product.title if project.product_id else "未绑定商品"
# 项目累计花费 = 该项目所有 AI 扣费流水之和(脚本/图片/视频/导出),无扣费则显示「-」 # 项目累计花费 = 该项目所有 AI 扣费流水之和(脚本/图片/视频/导出),无扣费则显示「-」
project_spend = project.credit_ledgers.filter( project_spend = spend_by_project.get(project.id)
ledger_type=CreditLedger.Type.CHARGE
).aggregate(total=Sum("amount"))["total"]
project_cost = f"¥{project_spend:.2f}" if project_spend else "-" project_cost = f"¥{project_spend:.2f}" if project_spend else "-"
create_once( create_once(
f"project:{project.id}:status:{project.status}:{project.current_stage}", f"project:{project.id}:status:{project.status}:{project.current_stage}",
@@ -280,15 +288,24 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
if isinstance(data, dict): if isinstance(data, dict):
# 分类 chip 计数取绝对总数(忽略当前 tab/搜索),与设计稿一致 # 分类 chip 计数取绝对总数(忽略当前 tab/搜索),与设计稿一致
base = self._recipient_scope() 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["unread_count"] = unread_count
data["type_counts"] = { data["type_counts"] = {
"all": base.count(), "all": counts["all"],
"unread": unread_count, "unread": counts["unread"],
"task": base.filter(notification_type="task").count(), "task": counts["task"],
"team": base.filter(notification_type="team").count(), "team": counts["team"],
"billing": base.filter(notification_type="billing").count(), "billing": counts["billing"],
"system": base.filter(notification_type="system").count(), "system": counts["system"],
} }
return response return response
@@ -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'),
),
]
+2
View File
@@ -27,6 +27,8 @@ class Product(TeamOwnedModel):
indexes = [ indexes = [
models.Index(fields=["team", "status"]), models.Index(fields=["team", "status"]),
models.Index(fields=["team", "category"]), models.Index(fields=["team", "category"]),
# 商品库默认按团队 + 创建时间倒序翻页
models.Index(fields=["team", "-created_at"]),
] ]
def __str__(self) -> str: def __str__(self) -> str:
@@ -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'),
),
]
+2
View File
@@ -26,6 +26,8 @@ class Project(TeamOwnedModel):
indexes = [ indexes = [
models.Index(fields=["team", "status"]), models.Index(fields=["team", "status"]),
models.Index(fields=["team", "current_stage"]), models.Index(fields=["team", "current_stage"]),
# 项目列表默认按团队 + 更新时间倒序
models.Index(fields=["team", "-updated_at"]),
] ]
def __str__(self) -> str: def __str__(self) -> str:
+19 -1
View File
@@ -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)。 > 为护住共享 CSS,拆两段做:**part 1 = 浮层/反馈(S3/S4/S9)**,part 2 = token/布局(S1/S2/S6/S7/S8)。