From 744d45cc6165fda750bbe2f7a0c9a9f4c07e3a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 29 Sep 2026 11:27:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(ads):=20=E8=BD=AE=E6=92=AD=E5=9B=BE?= =?UTF-8?q?=E8=A3=82=E5=9B=BE=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E5=B9=BF?= =?UTF-8?q?=E5=91=8A=E7=8E=B0=E7=AD=BE=20TTL=2024h=20+=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=8A=82=E6=B5=81=E9=87=8D=E6=8B=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:广告图现签 900s(照片域 TTL),观众长留直播页超时后重拉 403。 广告为匿名公开内容,短轮换无安全收益——专属 AD_SIGNED_URL_TTL_SECONDS=24h; 另加兜底:img error → 页面层 60s 冷却重拉广告列表换新签名 URL。 Co-Authored-By: Claude Code --- apps/viewer/src/__tests__/ad-carousel.spec.ts | 13 +++++++++ apps/viewer/src/components/ads/AdCarousel.vue | 20 +++++++++----- apps/viewer/src/pages/ActivityPage.vue | 27 ++++++++++++++++++- packages/contracts/src/constants.ts | 4 +++ .../api/src/public-ov/public-ov.service.ts | 11 +++++--- 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/viewer/src/__tests__/ad-carousel.spec.ts b/apps/viewer/src/__tests__/ad-carousel.spec.ts index 01ecf81..31727e4 100644 --- a/apps/viewer/src/__tests__/ad-carousel.spec.ts +++ b/apps/viewer/src/__tests__/ad-carousel.spec.ts @@ -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; @@ -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 + }); }); diff --git a/apps/viewer/src/components/ads/AdCarousel.vue b/apps/viewer/src/components/ads/AdCarousel.vue index a15139c..b005ef1 100644 --- a/apps/viewer/src/components/ads/AdCarousel.vue +++ b/apps/viewer/src/components/ads/AdCarousel.vue @@ -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)" > - + {{ t('ads.label') }} @@ -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; diff --git a/apps/viewer/src/pages/ActivityPage.vue b/apps/viewer/src/pages/ActivityPage.vue index a93e0ae..a6f5f7a 100644 --- a/apps/viewer/src/pages/ActivityPage.vue +++ b/apps/viewer/src/pages/ActivityPage.vue @@ -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(() => { > diff --git a/packages/contracts/src/constants.ts b/packages/contracts/src/constants.ts index 73faa82..cabd0a7 100644 --- a/packages/contracts/src/constants.ts +++ b/packages/contracts/src/constants.ts @@ -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; diff --git a/services/api/src/public-ov/public-ov.service.ts b/services/api/src/public-ov/public-ov.service.ts index a423b3a..143e2d4 100644 --- a/services/api/src/public-ov/public-ov.service.ts +++ b/services/api/src/public-ov/public-ov.service.ts @@ -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, })), );