fix(ads): 轮播图裂图修复——广告现签 TTL 24h + 失败节流重拉

根因:广告图现签 900s(照片域 TTL),观众长留直播页超时后重拉 403。
广告为匿名公开内容,短轮换无安全收益——专属 AD_SIGNED_URL_TTL_SECONDS=24h;
另加兜底:img error → 页面层 60s 冷却重拉广告列表换新签名 URL。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 11:27:06 +08:00
parent 8741bded7f
commit 744d45cc61
5 changed files with 65 additions and 10 deletions
@@ -35,6 +35,11 @@ function currentSrc(wrapper: VueWrapper): string | null {
return img?.getAttribute('src') ?? null;
}
/** 图加载失败(含现签 URL 过期)→ emit imageError(adId)(2026-09-29 裂图自愈)。 */
function triggerImgError(wrapper: VueWrapper): void {
wrapper.find('.ad-slide img').trigger('error');
}
describe('AdCarousel', () => {
let openSpy: ReturnType<typeof spyWindowOpen>;
@@ -114,4 +119,12 @@ describe('AdCarousel', () => {
await vi.advanceTimersByTimeAsync(12000);
expect(currentSrc(wrapper)).toBe('https://ad.test/solo.jpg');
});
it('emits imageError with adId when the current image fails to load', async () => {
const wrapper = mountCarousel(ADS);
triggerImgError(wrapper);
const emitted = wrapper.emitted('imageError');
expect(emitted).toHaveLength(1);
expect(emitted![0]).toEqual(['a']); // 当前帧(index 0)的 adId
});
});
+14 -6
View File
@@ -15,7 +15,9 @@ import { nextIndex, swipeStep } from '../../ads/carousel';
const ROTATE_MS = 4000;
const props = defineProps<{ ads: PublicAd[] }>();
const emit = defineEmits<{ close: [] }>();
// image-error:当前图加载失败(含现签 URL 过期)上报 adId——页面层节流重拉
// 广告列表换新签名 URL(组件保持无副作用,2026-09-29 裂图修复)。
const emit = defineEmits<{ close: []; imageError: [adId: string] }>();
const { t } = useI18n();
@@ -101,7 +103,13 @@ function openAd(ad: PublicAd): void {
v-if="currentAd !== null" type="button" class="ad-slide" :aria-label="t('ads.open')"
@click="openAd(currentAd)"
>
<img :src="currentAd.imageUrl" :alt="t('ads.label')" loading="lazy" draggable="false">
<img
:src="currentAd.imageUrl"
:alt="t('ads.label')"
loading="lazy"
draggable="false"
@error="currentAd !== null && emit('imageError', currentAd.adId)"
>
</button>
<span class="ad-badge">{{ t('ads.label') }}</span>
<button type="button" class="ad-close" :aria-label="t('ads.close')" @click="emit('close')">✕</button>
@@ -122,8 +130,8 @@ function openAd(ad: PublicAd): void {
.ad-carousel {
position: relative;
width: 100%;
aspect-ratio: 10 / 3; /* 建议素材 1200×360 */
margin-bottom: 16px;
/* aspect-ratio: 10 / 3; 建议素材 1200×360 */
margin: 16px 0;
overflow: hidden;
border-radius: var(--viewer-radius-img);
background: var(--viewer-dark-bg); /* 图像加载期的暗色衬底 */
@@ -163,8 +171,8 @@ function openAd(ad: PublicAd): void {
position: absolute;
top: 0;
right: 0;
width: var(--viewer-touch-target); /* ≥44px 触达 */
height: var(--viewer-touch-target);
width:24px; /* ≥44px 触达 */
height: 24px;
border: 0;
border-radius: 50%;
font-size: 18px;
+26 -1
View File
@@ -126,6 +126,26 @@ function dismissAds(): void {
ads.value = [];
}
/** 裂图自愈(2026-09-29):图加载失败(含现签 URL 过期)→ 节流重拉广告列表
* 换新签名 URL。冷却 60s:真坏图(对象缺失)重拉也无济于事,防循环;关闭态
* 不再打扰。 */
const ADS_REFRESH_COOLDOWN_MS = 60_000;
let adsLastRefreshAt = 0;
function refreshAdsOnImageError(): void {
if (sessionStorage.getItem('viewer.adsDismissed') === '1') return;
const now = Date.now();
if (now - adsLastRefreshAt < ADS_REFRESH_COOLDOWN_MS) return;
adsLastRefreshAt = now;
getPublicAds(token.value)
.then((result) => {
ads.value = result.items;
})
.catch(() => {
/* 静默:保持现状,下一轮播错误再试 */
});
}
// —— 照片序列(Task 8 提升:feed + lightbox 共享) ——
/** 排序模式(Task 10):latest=发布倒序(缺省)/ hot=点赞倒序短期快照。
@@ -366,7 +386,12 @@ onBeforeUnmount(() => {
>
<!-- 广告轮播:宣传图与标题之间(用户定稿 2026-09-29;无 banner 时仍居标题上方)。 -->
<template #after-banner>
<AdCarousel v-if="ads.length > 0" :ads="ads" @close="dismissAds" />
<AdCarousel
v-if="ads.length > 0"
:ads="ads"
@close="dismissAds"
@image-error="refreshAdsOnImageError"
/>
</template>
</AlbumHeader>
+4
View File
@@ -65,3 +65,7 @@ export const EMBED_WINDOW_SECONDS = 60;
/** 广告图字节上限(2MB):广告不走变体流水线、原图直出且轮播多张同屏,
* 比 UPLOAD_MAX_BYTES(50MB,照片域)收紧(设计 §3)。 */
export const AD_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
/** 广告图现签 GET TTL(24h):观众在直播页停留常超照片域 900s 短 TTL,
* 广告又是匿名公开内容(短轮换无安全收益)——专属放宽防长会话裂图
* (2026-09-29 线上裂图修复;照片域 SIGNED_URL_TTL_SECONDS 语义不变)。 */
export const AD_SIGNED_URL_TTL_SECONDS = 24 * 60 * 60;
@@ -25,7 +25,7 @@
// 脏数据整页失败);defaultLocale 翻译条目缺失属装配异常 → 5xx 兜底。
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import { ErrorCodes } from '@photo/contracts';
import { AD_SIGNED_URL_TTL_SECONDS, ErrorCodes } from '@photo/contracts';
import { ApiException } from '../api-exception';
import { ALBUM_LOCALES, type AlbumLocale } from '../albums/dto';
import { validationFailed } from '../albums/support';
@@ -322,11 +322,16 @@ export class PublicOvService {
(r) =>
r.albumIds === null || (r.albumIds as string[]).includes(album.albumId),
);
const ttl = signedUrlTtlSeconds();
// 广告现签 TTL 用专属常量(24h)而非照片域的 SIGNED_URL_TTL_SECONDS(900s):
// 观众在图片直播页停留常超 15 分钟,短 TTL 会使轮播图在长会话中裂图;
// 广告为匿名公开内容(任何持 token 访客可见),短轮换无安全收益。
const items = await Promise.all(
visible.map(async (r) => ({
adId: r.adId,
imageUrl: await this.storage.presignGet(r.imageObjectKey, ttl),
imageUrl: await this.storage.presignGet(
r.imageObjectKey,
AD_SIGNED_URL_TTL_SECONDS,
),
linkUrl: r.linkUrl,
})),
);