diff --git a/apps/mobile/android/app/proguard-rules.pro b/apps/mobile/android/app/proguard-rules.pro index 5c2325d..bc62c84 100644 --- a/apps/mobile/android/app/proguard-rules.pro +++ b/apps/mobile/android/app/proguard-rules.pro @@ -6,3 +6,13 @@ # OkHttp(原生上传传输):公开 API 保留即可,但 safety net 防混淆异常 -dontwarn okhttp3.** -dontwarn okio.** + +# ── release 网络/存储修复(2026-09-23:release 包 API 全报 INTERNAL_ERROR)── +# Dio interceptor 链经动态代理:R8 裁剪接口/代理导致请求异常 +-keep class dio.** { *; } +-keep interface dio.** { *; } +# flutter_secure_storage(release Keystore 行为) +-keep class com.it_nomads.fluttersecurestorage.** { *; } +# JSON 模型(fromJson 保守保留) +-keep class com.photolive.photo_live.** { *; } +-keep class dev.flutter.plugins.** { *; } diff --git a/apps/viewer/e2e/dbg-outside.spec.ts b/apps/viewer/e2e/dbg-outside.spec.ts new file mode 100644 index 0000000..5dfe190 --- /dev/null +++ b/apps/viewer/e2e/dbg-outside.spec.ts @@ -0,0 +1,97 @@ +// 临时窗外释放调试(验证后删除) +import { createServer, type Server } from 'node:http'; +import { expect, test, type Page } from '@playwright/test'; +import { + cleanupSeededAlbums, createEmbedSlot, flushOutbox, flushSync, + seedAlbum, seedPhoto, updateEmbedSlot, +} from './helpers'; + +const HOST_PORT = 3976; +const MINUTE = 60_000; +let slotToken = ''; + +test.beforeAll(async () => { + const album = await seedAlbum({ + alias: 'outsidedbg', defaultLocale: 'zh-CN', + translations: [{ locale: 'zh-CN', title: '窗外释放调试' }], + }); + for (let i = 1; i <= 6; i++) { + await seedPhoto(album.id, `outsidedbg-p${i}`, { publishedAt: new Date(Date.now() - (7 - i) * MINUTE) }); + } + await flushSync(album.id); + const slot = await createEmbedSlot('outside-dbg'); + await updateEmbedSlot(slot.id, { + albumId: album.id, + settings: { + position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false, + imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000, + displayMode: 'vertical-stream', scrollSpeed: 16, reactionsEnabled: true, + }, + }); + await flushOutbox(); + slotToken = slot.token; +}); + +const server: Server = createServer((_q, res) => { + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.end(` + + `); +}); +test.afterAll(async () => { await cleanupSeededAlbums(); server.close(); }); + +test('outside release debug', async ({ page }: { page: Page }) => { + page.on('console', (m) => { + const txt = m.text(); + if (txt.includes('stream-frame-error') || txt.includes('host-msg') || txt.includes('[ev]')) { + console.log(txt.slice(0, 200)); + } + }); + await new Promise((r) => server.listen(HOST_PORT, '127.0.0.1', r)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(`http://127.0.0.1:${HOST_PORT}`); + const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); + await frame.waitFor({ timeout: 20000 }); + await page.waitForTimeout(3000); + const f = page.frames().find((fr) => fr.url().includes('v=2')); + expect(f).toBeDefined(); + const offset = async () => { + const t = await f!.locator('.stream-track').evaluate((el) => el.style.transform); + return Number.parseFloat((/translateY\((-?[\d.]+)px\)/.exec(t) ?? ['0', '0'])[1]); + }; + const box = await frame.boundingBox(); + const cx = (box ? box.x : 0) + 90; + const cy = (box ? box.y : 0) + 150; + + const b1 = await offset(); + await page.waitForTimeout(1000); + console.log('[1 baseline]', b1 !== (await offset())); + + await f!.evaluate(() => { + const log = (name: string) => (e: PointerEvent) => + console.log(`[ev] ${name} type=${e.type} x=${e.clientX} y=${e.clientY}`); + const vp = document.querySelector('.stream-viewport'); + vp?.addEventListener('pointerdown', log('vp'), true); + window.addEventListener('pointerup', log('win'), true); + window.addEventListener('pointercancel', log('win'), true); + window.addEventListener('pointerleave', log('win'), true); + window.addEventListener('pointerout', log('win'), true); + vp?.addEventListener('pointerleave', log('vp'), true); + }); + await page.mouse.move(cx, cy); + await page.mouse.down(); + await page.mouse.move(cx + 120, cy + 200, { steps: 4 }); + await page.mouse.up(); + await page.waitForTimeout(3500); + const w1 = await offset(); + await page.waitForTimeout(1000); + console.log('[2 after outside]', w1 !== (await offset()), 'offset =', w1); + console.log('[2 state]', await f!.evaluate(() => { + const d = (window as unknown as { __embedDebug?: { reasons: Set; userPaused: { value: boolean } } }).__embedDebug; + return `reasons=[${d ? [...d.reasons].join(',') : '?'}] userPaused=${d?.userPaused.value}`; + })); + + const s1 = await offset(); + await page.waitForTimeout(15000); + console.log('[3 15s moved]', Math.abs((await offset()) - s1).toFixed(0), 'px'); +}); diff --git a/apps/viewer/e2e/verify-final2.mjs b/apps/viewer/e2e/verify-final2.mjs new file mode 100644 index 0000000..24e2ea0 --- /dev/null +++ b/apps/viewer/e2e/verify-final2.mjs @@ -0,0 +1,54 @@ +import { createServer } from 'node:http'; +import { chromium } from '@playwright/test'; +const server = createServer((_q, res) => { + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.end(` + `); +}); +await new Promise((r) => server.listen(3977, '127.0.0.1', r)); +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +await page.goto('http://127.0.0.1:3977'); +const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); +await frame.waitFor({ timeout: 20000 }); +await page.waitForTimeout(3000); +const f = page.frames().find((fr) => fr.url().includes('v=2')); +const offset = async () => { + const t = await f.locator('.stream-track').evaluate((el) => el.style.transform); + return Number.parseFloat((/translateY\((-?[\d.]+)px\)/.exec(t) ?? ['0', '0'])[1]); +}; +const box = await frame.boundingBox(); +const cx = (box ? box.x : 0) + 90, cy = (box ? box.y : 0) + 150; +const scrolling = async (ms = 1000) => { const a = await offset(); await page.waitForTimeout(ms); return a !== (await offset()); }; + +console.log('1. baseline scrolling =', await scrolling()); + +// 窗内拖动 → 鼠标移开 → 恢复 +await page.mouse.move(cx, cy); await page.mouse.down(); +await page.mouse.move(cx, cy + 40, { steps: 4 }); await page.mouse.up(); +await page.mouse.move(30, 30); await page.waitForTimeout(1500); +console.log('2. after in-frame drag (pointer away) =', await scrolling()); + +// 窗外释放 → 2s 看门狗 → 恢复 +await page.mouse.move(cx, cy); await page.mouse.down(); +await page.mouse.move(cx + 120, cy + 200, { steps: 4 }); await page.mouse.up(); +await page.waitForTimeout(3500); +console.log('3. after outside-release =', await scrolling()); + +// 长跑 30s +const s1 = await offset(); await page.waitForTimeout(30000); const s2 = await offset(); +console.log('4. 30s moved =', Math.abs(s2 - s1).toFixed(0), 'px (expect ~480)'); + +// hover 语义 +await page.mouse.move(cx, cy); await page.waitForTimeout(800); +const h1 = await offset(); await page.waitForTimeout(800); const h2 = await offset(); +await page.mouse.move(30, 30); await page.waitForTimeout(1500); +const h3 = await offset(); await page.waitForTimeout(800); const h4 = await offset(); +console.log('5. hover pause =', h1 === h2, '; leave resume =', h3 !== h4); + +// 点击弹框仍正常 +await page.mouse.click(cx, cy); +await page.locator('iframe[title="PhotoLive photo detail"]').waitFor({ timeout: 8000 }); +console.log('6. photo click modal works'); +await page.screenshot({ path: '../../docs/designs/ui04-implementation/actual/server/freeze-fixed-final.png' }); +server.close(); await browser.close(); diff --git a/apps/viewer/src/embed/useVerticalPhotoStream.ts b/apps/viewer/src/embed/useVerticalPhotoStream.ts index 8200062..81b4e0e 100644 --- a/apps/viewer/src/embed/useVerticalPhotoStream.ts +++ b/apps/viewer/src/embed/useVerticalPhotoStream.ts @@ -118,8 +118,10 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) { writeTransform(); } } - } catch { - /* 异常帧:跳过本次推进,链继续(数据边缘态由下次 reanchor 修复) */ + } catch (err) { + /* 异常帧:跳过本次推进,链继续(数据边缘态由下次 reanchor 修复)。 + * 临时探针(滚动卡死排查用,随后移除)。 */ + console.error('[stream-frame-error]', err); } if (running.value) { rafId = requestAnimationFrame(frame); diff --git a/apps/viewer/src/pages/embed/EmbedStream.vue b/apps/viewer/src/pages/embed/EmbedStream.vue index c3b95ba..0a7f946 100644 --- a/apps/viewer/src/pages/embed/EmbedStream.vue +++ b/apps/viewer/src/pages/embed/EmbedStream.vue @@ -155,6 +155,10 @@ function simulateBurst(photoId: string, delta: number): void { } defineExpose({ simulateBurst }); +// 临时探针(卡死排查,随后移除) +if (typeof window !== 'undefined') { + (window as unknown as Record).__embedDebug = { reasons: pauseReasons, userPaused }; +} // 收起/隐藏/换绑/减少动效 → 清粒子(§5.3)。 watch(activeRef, (value) => { @@ -258,7 +262,7 @@ function onPointerMove(event: PointerEvent): void { } } -function onPointerUp(): void { +function onPointerUp(event: PointerEvent): void { pointerDownY = null; if (pointerWatchdog !== null) { clearTimeout(pointerWatchdog); @@ -269,6 +273,20 @@ function onPointerUp(): void { // 滚动停死到刷新;滚轮/键盘路径各有 500ms 定时清除,拖动漏了)。 setReason('manual', false); setReason('pointer', false); + // 窗外结束时(拖出窗外 Chrome 派发 pointercancel 而非 pointerleave——leave + // 不会再来)hover 原因同样会永久残留,这里按坐标判定一并清除;窗内正常 + // 释放不清(点击后悬停继续暂停是合理行为)。 + const rect = viewportEl.value?.getBoundingClientRect(); + if ( + rect !== undefined && + (event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom) + ) { + hoverPointerIsMouse = false; + setReason('hover', false); + } scheduleResume(); } @@ -385,6 +403,7 @@ function onWindowBlur(): void { setReason('pointer', false); setReason('manual', false); setReason('focus', false); + setReason('hover', false); } onMounted(() => { diff --git a/docs/designs/ui04-implementation/actual/server/photo-modal.png b/docs/designs/ui04-implementation/actual/server/photo-modal.png index 8303072..ffbc98e 100644 Binary files a/docs/designs/ui04-implementation/actual/server/photo-modal.png and b/docs/designs/ui04-implementation/actual/server/photo-modal.png differ diff --git a/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm b/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm index 8f01596..d27e882 100644 Binary files a/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm and b/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm differ diff --git a/docs/designs/ui04-implementation/recordings/e05-hearts-16s.webm b/docs/designs/ui04-implementation/recordings/e05-hearts-16s.webm index f8244cd..ff89133 100644 Binary files a/docs/designs/ui04-implementation/recordings/e05-hearts-16s.webm and b/docs/designs/ui04-implementation/recordings/e05-hearts-16s.webm differ diff --git a/infra/deploy/nginx.conf.template b/infra/deploy/nginx.conf.template index 6afec06..94905dd 100644 --- a/infra/deploy/nginx.conf.template +++ b/infra/deploy/nginx.conf.template @@ -36,9 +36,24 @@ server { gzip_min_length 1024; gzip_types text/css application/javascript application/json image/svg+xml; - # /v1 → API 容器(viewer/admin 前端均为同源相对路径请求)。 - location /v1/ { - proxy_pass ${API_UPSTREAM}; + # API 路径禁用 gzip(2026-09-23:Flutter release AOT 的 HTTP 客户端 + # 对 gzip 响应解压异常——debug/JIT 正常,release/AOT 全挂 INTERNAL_ERROR; + # 静态资源 gzip 不受影响)。 + location ^~ /v1/ { + gzip off; + proxy_pass http://${API_UPSTREAM}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + +; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr;