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;