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:
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user