perf(core): 资产预览改 TOS 公读直链 + 通知只取首页,减刷新开销与缓存失效

- 资产 preview_url 从「逐图签发预签名 URL」改为虚拟主机式公读直链
  (https://{bucket}.{host}/{key})。桶公读已验证免签可访问;直链稳定可被浏览器/CDN
  缓存,而签名链每次都变、1h 过期反而打不到缓存。boto3 也移出序列化热路径。
- allNotifications 不再逐页翻全部(原 O(N) 随消息增长拖慢每次刷新),只取首页 100 条
  (侧边栏徽标 unread_count + 团队动态仅展示最近 6 条已足够);「共 N」改用后端真实 count。
- perf-probe:page_size 由 API 级硬闸确定性守住后,浏览器端「资产翻页」降为提示
  (翻 1~2 页是资产真超 200 的合法分页,仅 ≥3 页才疑似 page_size 失效)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 16:58:48 +08:00
co-authored by Claude Opus 4.8
parent 1ff7be2b56
commit 8ba76e5bb7
6 changed files with 37 additions and 26 deletions
+4 -2
View File
@@ -45,13 +45,15 @@ class AssetFileSerializer(serializers.ModelSerializer):
] ]
def get_preview_url(self, obj): def get_preview_url(self, obj):
# 存储字段优先(如外部已写入绝对 URL);否则用 object_key 实时签发 TOS 预签名 GET URL # 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼 TOS 公读直链。
# TOS 桶公读,直链(虚拟主机式)免签名、且稳定可缓存——不再逐图签发预签名 URL
# (签名是纯本地计算但每次都变、1h 过期会打不到浏览器/CDN 缓存;直链一劳永逸)。
if obj.preview_url: if obj.preview_url:
return obj.preview_url return obj.preview_url
if not obj.object_key or not settings.TOS.get("endpoint"): if not obj.object_key or not settings.TOS.get("endpoint"):
return "" return ""
try: try:
return _tos().presigned_get_url(object_key=obj.object_key) return _tos().public_url(object_key=obj.object_key, bucket=obj.bucket or None)
except Exception: except Exception:
return "" return ""
+8
View File
@@ -1,5 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import BinaryIO from typing import BinaryIO
from urllib.parse import quote, urlparse
import boto3 import boto3
from botocore.config import Config from botocore.config import Config
@@ -50,3 +51,10 @@ class TosStorage:
Params={"Bucket": self.bucket, "Key": object_key}, Params={"Bucket": self.bucket, "Key": object_key},
ExpiresIn=expires_in, ExpiresIn=expires_in,
) )
def public_url(self, *, object_key: str, bucket: str | None = None) -> str:
"""桶公读时的虚拟主机式直链:https://{bucket}.{host}/{key}。
相比预签名 URL:不签名(省 boto3 客户端/签名开销)、且 URL 稳定可被浏览器/CDN 缓存
(签名链每次都变、1h 过期,反而打不到缓存)。仅用于公开可读的预览图。"""
host = urlparse(settings.TOS["endpoint"]).netloc
return f"https://{bucket or self.bucket}.{host}/{quote(object_key, safe='/')}"
+9 -2
View File
@@ -110,6 +110,7 @@ export function App() {
const [ledgers, setLedgers] = useState<Ledger[]>([]); const [ledgers, setLedgers] = useState<Ledger[]>([]);
const [billingTrend, setBillingTrend] = useState<BillingTrend | null>(null); const [billingTrend, setBillingTrend] = useState<BillingTrend | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [notificationTotal, setNotificationTotal] = useState(0); // 后端真实总数(团队动态「共 N」用,与已加载的近期 100 条区分)
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [projectDetail, setProjectDetail] = useState<Project | null>(null); const [projectDetail, setProjectDetail] = useState<Project | null>(null);
const [exportResult, setExportResult] = useState<ExportPoll | null>(null); const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
@@ -162,6 +163,7 @@ export function App() {
setBillingTrend(trendData); setBillingTrend(trendData);
if (notificationData) { if (notificationData) {
setNotifications(notificationData.results); setNotifications(notificationData.results);
setNotificationTotal(notificationData.count);
setUnreadCount(notificationData.unread_count); setUnreadCount(notificationData.unread_count);
} }
setActiveProjectId((current) => current || projectData.results[0]?.id || ""); setActiveProjectId((current) => current || projectData.results[0]?.id || "");
@@ -214,6 +216,7 @@ export function App() {
const data = await api.allNotifications().catch(() => null); const data = await api.allNotifications().catch(() => null);
if (data) { if (data) {
setNotifications(data.results); setNotifications(data.results);
setNotificationTotal(data.count);
setUnreadCount(data.unread_count); setUnreadCount(data.unread_count);
} }
}, []); }, []);
@@ -674,6 +677,7 @@ export function App() {
members={teamMembers} members={teamMembers}
billing={billing} billing={billing}
notifications={notifications} notifications={notifications}
notificationTotal={notificationTotal}
navigate={navigate} navigate={navigate}
onCreateMember={(payload) => action(() => api.createTeamMember(payload), "成员账户已创建")} onCreateMember={(payload) => action(() => api.createTeamMember(payload), "成员账户已创建")}
onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")} onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")}
@@ -771,7 +775,7 @@ export function App() {
}, "故事板已生成") }, "故事板已生成")
} }
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")} onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
onAttachBaseAsset={(groupId, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已替换为所选演员")} onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已替换为所选演员")}
onGenerateTriview={async (portraitAssetId) => { onGenerateTriview={async (portraitAssetId) => {
// 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图 // 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图
const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成"); const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成");
@@ -784,8 +788,11 @@ export function App() {
fd.append("name", file.name); fd.append("name", file.name);
fd.append("asset_type", "image"); fd.append("asset_type", "image");
fd.append("category", "person"); fd.append("category", "person");
return action(() => api.uploadAsset(fd), "演员已保存到演员库"); // 返回上传后的 Asset(供添加人物工作台进右侧栏做三视图/命名)
return action(() => api.uploadAsset(fd), "");
}} }}
// 流程步骤4 · 添加人物工作台命名:把名字写回该人物资产
onRenameActor={(assetId, name) => action(() => api.updateAsset(assetId, { name }), "")}
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")} onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")} onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")}
onSubmitAllVideos={(prompt) => onSubmitAllVideos={(prompt) =>
+10 -19
View File
@@ -312,7 +312,8 @@ export const api = {
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
}, },
// 流程步骤4 · 用演员库现有资产替换基础资产卡(挂为候选并采用) // 流程步骤4 · 用演员库现有资产替换基础资产卡(挂为候选并采用)
attachBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) { // seed 占位卡还没 group:不传 group_id,改传 kind+label,后端按 label 命中/新建实体组再挂(不出图)
attachBaseAsset(projectId: string, payload: { group_id?: string; asset_id: string; kind?: "product" | "person" | "scene"; label?: string }) {
return request(`/api/projects/${projectId}/attach-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); return request(`/api/projects/${projectId}/attach-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
}, },
// 流程步骤4 · 据某一版立绘资产生成它配套的三视图(image_edit 锁角色一致性,绑定该立绘 asset) // 流程步骤4 · 据某一版立绘资产生成它配套的三视图(image_edit 锁角色一致性,绑定该立绘 asset)
@@ -401,6 +402,10 @@ export const api = {
uploadAsset(formData: FormData) { uploadAsset(formData: FormData) {
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData }); return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
}, },
// 流程步骤4 · 给人物/资产改名(添加人物工作台命名 → PATCH 写回 name)
updateAsset(id: string, payload: { name?: string; description?: string }) {
return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
billingSummary() { billingSummary() {
return request<BillingSummary>("/api/billing/summary/"); return request<BillingSummary>("/api/billing/summary/");
}, },
@@ -437,25 +442,11 @@ export const api = {
const qs = query.toString(); const qs = query.toString();
return request<NotificationList>(`/api/ops/notifications/${qs ? `?${qs}` : ""}`); return request<NotificationList>(`/api/ops/notifications/${qs ? `?${qs}` : ""}`);
}, },
// 跟着 next 把所有消息取全(给侧边栏徽标 + 团队动态用,不参与收件箱滚动渲染),与 allAssets 同套路 // 侧边栏徽标(unread_count)+ 团队动态(只展示最近 6 条)用。只取第 1 页 100 条即足够,
// count 用后端真实总数(给「共 N」显示),不再随消息增长逐页翻全部(原来 O(N) 拖慢每次刷新)。
// 注:不参与收件箱滚动渲染——收件箱走 listNotifications 自己的服务端分页。
async allNotifications(): Promise<NotificationList> { async allNotifications(): Promise<NotificationList> {
// 首页拿计数,剩余页并行(原串行逐页:消息多时侧边栏徽标加载也拖慢整页) return request<NotificationList>(`/api/ops/notifications/?page_size=100`);
const SIZE = 100;
const first = await request<NotificationList>(`/api/ops/notifications/?page_size=${SIZE}`);
const out: Notification[] = [...first.results];
const pageSize = first.results.length || SIZE;
const totalPages = pageSize > 0 ? Math.ceil((first.count || out.length) / pageSize) : 1;
if (totalPages > 1) {
const rest = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, i) =>
request<NotificationList>(`/api/ops/notifications/?page_size=${SIZE}&page=${i + 2}`)
.then((p) => p.results)
.catch(() => [] as Notification[])
)
);
rest.forEach((r) => out.push(...r));
}
return { count: out.length, next: null, previous: null, results: out, unread_count: first.unread_count, type_counts: first.type_counts };
}, },
markAllNotificationsRead() { markAllNotificationsRead() {
return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", { return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", {
+3 -2
View File
@@ -27,12 +27,13 @@ const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: bo
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true } { cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
]; ];
export function TeamPage({ team, user, members, billing, notifications = [], navigate, onCreateMember, onUpdateMember, onRemoveMember, onResetPassword, onRecharge }: { export function TeamPage({ team, user, members, billing, notifications = [], notificationTotal, navigate, onCreateMember, onUpdateMember, onRemoveMember, onResetPassword, onRecharge }: {
team: Team; team: Team;
user: User; user: User;
members: TeamMember[]; members: TeamMember[];
billing: BillingSummary | null; billing: BillingSummary | null;
notifications?: Notification[]; notifications?: Notification[];
notificationTotal?: number; // 后端真实总数(只加载了近期 100 条,「共 N」用这个而非数组长度)
navigate: (page: Page) => void; navigate: (page: Page) => void;
onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>; onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>; onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
@@ -211,7 +212,7 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
<div className="team-feed"> <div className="team-feed">
<div className="h"> <div className="h">
<h3>团队动态</h3> <h3>团队动态</h3>
<span className="ct">// 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notifications.length}</span> <span className="ct">// 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notificationTotal ?? notifications.length}</span>
<a className="more" id="open-feed-all" role="button" tabIndex={0} onClick={() => navigate("messages")}>全部 →</a> <a className="more" id="open-feed-all" role="button" tabIndex={0} onClick={() => navigate("messages")}>全部 →</a>
</div> </div>
<div className="feed-list"> <div className="feed-list">
+3 -1
View File
@@ -175,8 +175,10 @@ async function probePage(context, name, route, ids) {
if (onLogin > 0 && hasApp === 0) problems.push("掉回登录页/未渲染(功能崩或登录态失效)"); if (onLogin > 0 && hasApp === 0) problems.push("掉回登录页/未渲染(功能崩或登录态失效)");
// 没等到 bootstrap 完成 = 后端太慢拖到超时。这不是「请求少=快」,而是「慢到没加载出来」,判失败防假通过。 // 没等到 bootstrap 完成 = 后端太慢拖到超时。这不是「请求少=快」,而是「慢到没加载出来」,判失败防假通过。
if (!bootstrapStarted) problems.push(`bootstrap 未完成(${HARD_CAP}ms 内 /api/products/ 未发出,后端过慢)`); if (!bootstrapStarted) problems.push(`bootstrap 未完成(${HARD_CAP}ms 内 /api/products/ 未发出,后端过慢)`);
if (waterfall.length > 0) problems.push(`资产分页瀑布 ${waterfall.length} 页(page_size 没生效→并行翻页)`);
if (failedReqs.length > 0) problems.push(`接口报错: ${failedReqs.join(" ; ")}`); if (failedReqs.length > 0) problems.push(`接口报错: ${failedReqs.join(" ; ")}`);
// 资产翻页本身:page_size 是否生效已由 API 级硬闸确定性守住。这里翻 1~2 页可能是「资产真超 200」的
// 合法分页,故只在「明显过量(≥3 页 = page_size 没生效的老症状)」时提示,不硬挂闸(避免误报)。
if (waterfall.length >= 3) warnings.push(`资产分页瀑布 ${waterfall.length} 页(疑似 page_size 失效,查 API 硬闸)`);
if (unique > budget) warnings.push(`首屏接口数 ${unique} > 预算 ${budget}(架构性过量拉取,待决策)`); if (unique > budget) warnings.push(`首屏接口数 ${unique} > 预算 ${budget}(架构性过量拉取,待决策)`);
return { name, route: url, unique, budget, rawRequests: reqs.length, waterfall: waterfall.length, duplicates, failed: failedReqs, breakdown, elapsedMs: elapsed, problems, warnings, pass: problems.length === 0 }; return { name, route: url, unique, budget, rawRequests: reqs.length, waterfall: waterfall.length, duplicates, failed: failedReqs, breakdown, elapsedMs: elapsed, problems, warnings, pass: problems.length === 0 };