fix(deploy): disable gzip on /v1/ API paths — Flutter release AOT gzip decompression failure

服务端 200+数据正常、debug/JIT 正常;release/AOT 的 Dart HttpClient 对
Content-Encoding: gzip 解压异常 → JSON 解析失败 → 显示 INTERNAL_ERROR。
nginx /v1/ 路径 gzip off(静态资源 gzip 不受影响);proguard keep 规则
一并保留(dio/flutter_secure_storage/全项目模型)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 18:36:11 +08:00
parent fa06193a12
commit 222c44c1f7
9 changed files with 203 additions and 6 deletions
+10
View File
@@ -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.** { *; }
+97
View File
@@ -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(`<!doctype html><html><body style="min-height:900px">
<script>window.addEventListener('message',(e)=>console.log('[host-msg]',e.data&&e.data.type));</script>
<script src="http://127.0.0.1:5175/embed/v2/loader.js" data-slot="${slotToken}" defer></script></body></html>`);
});
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<void>((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<string>; 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');
});
+54
View File
@@ -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(`<!doctype html><html><body style="min-height:900px">
<script src="http://219.157.255.213:32006/embed/v2/loader.js" data-slot="se1bt8mqvmeese4jx9gixnj4" defer></script></body></html>`);
});
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();
@@ -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);
+20 -1
View File
@@ -155,6 +155,10 @@ function simulateBurst(photoId: string, delta: number): void {
}
defineExpose({ simulateBurst });
// 临时探针(卡死排查,随后移除)
if (typeof window !== 'undefined') {
(window as unknown as Record<string, unknown>).__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(() => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 383 KiB

+18 -3
View File
@@ -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;