Phase 12 v9.2: download-first + backend open-links (Word 16.101+ broke ms-word)
การทดสอบ 6 รอบบนเครื่องจริง (Mac, Word 16.101) พิสูจน์ว่า ms-word:ofe
ถูกเมินแบบไม่ deterministic ทุกรูปแบบ (http/https/query/ไม่มี query/
file:// — Word ไม่ยิง request ถึง server เลย เด้งหน้าเลือกไฟล์แทน)
open -a Word <local file> ใช้ได้แต่เรียกจากเว็บไม่ได้ (sandbox +
backend รัน server กลางใน production) → สรุปทางเดียวที่ deterministic
คือ download-first
- Backend: OpenLinkService (HMAC อายุสั้น ValidateOnStart) +
OpenController /api/open/{t}/{e}/v/{did}/{vid}/file.docx และ /d/{objectKey}
— ทุกอย่างอยู่ใน path ไม่มี query string (Word เมิน URL มี query),
[AllowAnonymous] ด้วยลายเซ็นแทน Bearer, stream จาก MinIO + disposition
ชื่อไทยเหมือนเดิม · download-link + generate-draft ออก open-link
แทน presigned ของ MinIO (http) · ลบ dead code presigned ทิ้ง
- Web: openDocxUrl = ดาวน์โหลดทันทีทุกครั้ง + ยิง ms-word ควบคู่แบบ
best-effort + notice "ไฟล์อยู่ Downloads" — เอา heuristic สลับแอป
ออก (โดนหน้าเลือกไฟล์ของ Word หลอกจนไม่ดาวน์โหลด)
- Spec v9.2 + ตารางทดสอบเครื่องจริงใน acceptance-review + CLAUDE.md
กฎ 13 (ms-word = best-effort เท่านั้น) + ทางเลือก helper อนาคต
- backend 86 / web 58 / add-in 66 + build ครบ; QA reviewer PASS
(1 round, minors 4 แก้ครบ)
หมายเหตุ deploy: ต้องตั้ง OpenLinks__Secret (ค่าลับจริง) และ
OpenLinks__BaseUrl (https domain ของ backend) — fail ดังตั้งแต่สตาร์ทถ้าไม่ตั้ง
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
e0558e3cf4
commit
ee4d8573ee
21 changed files with 340 additions and 145 deletions
|
|
@ -1,36 +1,27 @@
|
|||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { versionDownloadPath, wordOpenUrl, type VersionFormat } from '../utils/versionDownload'
|
||||
|
||||
/** Shared by history and generate success. Links stay available even if the focus heuristic fires. */
|
||||
/** Shared by history and generate success — ลิงก์เปิดอายุสั้นของ backend (open-link) ใช้ทั้งดาวน์โหลดและเปิดแบบ best-effort */
|
||||
export function useVersionOpen() {
|
||||
const { apiJson } = useApi()
|
||||
const openingVersionId = ref<string | null>(null)
|
||||
const downloadError = ref<string | null>(null)
|
||||
const fallbackUrl = ref<string | null>(null)
|
||||
const openNotice = ref<string | null>(null)
|
||||
let cleanup = () => {}
|
||||
let generation = 0
|
||||
let pendingTab: Window | null = null
|
||||
|
||||
/**
|
||||
* รอสังเกตการสลับไป Word (blur/visibilitychange) กี่มิลลิวินาทีก่อนสรุปว่าเปิดไม่สำเร็จ —
|
||||
* ยาวพอให้ Word cold-start โอกาสเปิดได้ทัน แต่ไม่นานจนผู้ใช้งง (ms-word:ofe บน macOS ตอน Word
|
||||
* ยังไม่เปิดค้างอยู่มักทำ URL event หลุด → เห็นหน้า Recent แทนเอกสาร — ข้อจำกัดของ platform)
|
||||
*/
|
||||
const WORD_SWITCH_WAIT_MS = 3000
|
||||
|
||||
function resetOpen() {
|
||||
generation++
|
||||
pendingTab?.close()
|
||||
pendingTab = null
|
||||
cleanup()
|
||||
openingVersionId.value = null
|
||||
downloadError.value = null
|
||||
fallbackUrl.value = null
|
||||
openNotice.value = null
|
||||
}
|
||||
|
||||
/** ให้เบราว์เซอร์เซฟ URL ลง Downloads (presigned URL ส่ง disposition: attachment อยู่แล้ว —
|
||||
/** ให้เบราว์เซอร์เซฟ URL ลง Downloads (ลิงก์เปิดของ backend ส่ง disposition: attachment อยู่แล้ว —
|
||||
* หน้าเว็บอยู่ที่เดิม ไม่ navigate ไปไหน) */
|
||||
function saveToDownloads(url: string) {
|
||||
const a = document.createElement('a')
|
||||
|
|
@ -40,30 +31,17 @@ export function useVersionOpen() {
|
|||
a.remove()
|
||||
}
|
||||
|
||||
/** เปิด presigned docx URL ใน Word ผ่าน ms-word:ofe — ถ้าไม่เห็นการสลับแอปภายใน WORD_SWITCH_WAIT_MS
|
||||
* ถือว่าเปิดไม่สำเร็จ: ดาวน์โหลดไฟล์ให้อัตโนมัติ + แจ้งผู้ใช้ (กดซ้ำตอน Word เปิดอยู่แล้วจะสำเร็จ)
|
||||
/** เปิดไฟล์ docx แบบ download-first (v9.2): ดาวน์โหลดไฟล์ให้ทันทีทุกครั้ง แล้วยิง ms-word:ofe
|
||||
* ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังทำงาน (Windows / Word รุ่นก่อน) —
|
||||
* เลิกพึ่ง heuristic การสลับแอป เพราะ Word for Mac 16.101+ (ก.ย. 2026) เมิน URL ทุกรูปแบบ
|
||||
* โดยเด้งหน้าเลือกไฟล์ (เหมือนสลับแอปจริง — heuristic โดนหลอกจนไม่ดาวน์โหลดให้)
|
||||
* ใช้กับลิงก์ที่ถืออยู่เองได้เลย (ไฟล์ร่าง FR-16: response ของ generate-draft คือ URL แล้ว) */
|
||||
function openDocxUrl(url: string) {
|
||||
resetOpen()
|
||||
fallbackUrl.value = url
|
||||
let switched = false
|
||||
const onBlur = () => { switched = true }
|
||||
const onVisibility = () => { if (document.hidden) switched = true }
|
||||
window.addEventListener('blur', onBlur)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!switched) {
|
||||
openNotice.value =
|
||||
'Word ไม่ได้เปิดเอกสารอัตโนมัติ (มักเกิดตอน Word เพิ่งถูกเปิดขึ้นครั้งแรก) — ดาวน์โหลดไฟล์ให้แล้วที่โฟลเดอร์ Downloads ให้เปิดจากที่นั่น หรือกดเปิดใน Word อีกครั้งเมื่อ Word เปิดอยู่แล้ว'
|
||||
saveToDownloads(url)
|
||||
}
|
||||
cleanup()
|
||||
}, WORD_SWITCH_WAIT_MS)
|
||||
cleanup = () => {
|
||||
window.clearTimeout(timer)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
window.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
saveToDownloads(url)
|
||||
openNotice.value =
|
||||
'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย (บางเครื่อง Word จะเปิดให้อัตโนมัติ)'
|
||||
window.location.assign(wordOpenUrl(url))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ async function submit() {
|
|||
}
|
||||
}
|
||||
|
||||
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอ presigned URL
|
||||
* แล้วเปิดใน Word ทันทีผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (เหมือน flow เปิด v0)
|
||||
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอ open-link (อายุสั้น)
|
||||
* แล้ว download-first เหมือน flow เปิด v0 (ดาวน์โหลดทันที + ยิง ms-word ควบคู่)
|
||||
* backend ไม่สร้าง Document/version — เอกสารจะเกิดตอนกด Start Versioning ใน Word (เป็น v1 พร้อมผูก template นี้) */
|
||||
async function submitDraft() {
|
||||
draftSuccess.value = null
|
||||
|
|
@ -129,7 +129,7 @@ async function submitDraft() {
|
|||
))!
|
||||
openDocxUrl(link.url)
|
||||
draftSuccess.value =
|
||||
'กำลังเปิดไฟล์ร่างใน Word — เปิด task pane ของ add-in แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
|
||||
'ดาวน์โหลดไฟล์ร่างให้แล้ว (โฟลเดอร์ Downloads) — เปิดไฟล์ใน Word แล้วกด Start Versioning This Document ใน task pane ของ add-in เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
|
||||
} catch (err) {
|
||||
draftError.value = err instanceof Error ? err.message : 'สร้างไฟล์ร่างไม่สำเร็จ'
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@ export function versionDownloadPath(documentId: string, versionId: string, forma
|
|||
}
|
||||
|
||||
/** Office URI Schemes: preserve the signed URL, including its existing percent escapes. */
|
||||
export function wordOpenUrl(presignedUrl: string): string {
|
||||
return `ms-word:ofe|u|${presignedUrl}`
|
||||
export function wordOpenUrl(fileUrl: string): string {
|
||||
return `ms-word:ofe|u|${fileUrl}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ function mount() {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('useApi', () => ({ apiJson }))
|
||||
tab = { opener: {}, closed: false, location: { replace }, close }
|
||||
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
|
||||
|
|
@ -30,7 +29,6 @@ afterEach(() => {
|
|||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('version open lifecycle', () => {
|
||||
|
|
@ -84,19 +82,16 @@ describe('version open lifecycle', () => {
|
|||
expect(state.downloadError.value).toBe('popup error')
|
||||
expect(state.openingVersionId.value).toBeNull()
|
||||
})
|
||||
it('offers Word fallback after timeout and removes listeners on unmount', async () => {
|
||||
it('openVersion (docx) ดาวน์โหลดทันที + ยิง ms-word + ตั้ง notice และเคลียร์ตอน unmount', async () => {
|
||||
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||
const remove = vi.spyOn(window, 'removeEventListener')
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) // กัน auto-download click จริง
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) // กัน download-first click จริง
|
||||
apiJson.mockResolvedValue({ url: 'https://files.example/v.docx?signature=a%2Fb' })
|
||||
const state = mount()
|
||||
await state.openVersion('d', 'v')
|
||||
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/v.docx?signature=a%2Fb')
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(state.openNotice.value).toBeTruthy()
|
||||
expect(state.fallbackUrl.value).toBeTruthy()
|
||||
app.unmount()
|
||||
expect(remove).toHaveBeenCalledWith('blur', expect.any(Function))
|
||||
})
|
||||
it('openDocxUrl opens a URL the caller already holds (ไฟล์ร่าง FR-16) without extra request', async () => {
|
||||
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||
|
|
@ -106,10 +101,9 @@ describe('version open lifecycle', () => {
|
|||
expect(apiJson).not.toHaveBeenCalled()
|
||||
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb')
|
||||
expect(state.fallbackUrl.value).toBe('https://files.example/draft.docx?sig=a%2Fb')
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(state.openNotice.value).toBeTruthy()
|
||||
})
|
||||
it('auto-downloads the file to Downloads when Word did not take over within the wait window', () => {
|
||||
it('download-first: ดาวน์โหลดไฟล์ทันทีที่กด (ไม่ต้องรอ heuristic — Word 16.101+ บน Mac โดนหลอกด้วยหน้าเลือกไฟล์)', () => {
|
||||
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||
const saved: HTMLAnchorElement[] = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) {
|
||||
|
|
@ -117,30 +111,13 @@ describe('version open lifecycle', () => {
|
|||
})
|
||||
const state = mount()
|
||||
state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb')
|
||||
// ยังไม่มีอะไรเกิดก่อนหมดเวลารอ
|
||||
vi.advanceTimersByTime(2999)
|
||||
expect(saved).toHaveLength(0)
|
||||
expect(state.openNotice.value).toBeNull()
|
||||
// ครบหน้าต่างรอ (ไม่มีการ blur = Word ไม่ได้เปิด) → เซฟไฟล์ให้อัตโนมัติ + แจ้งเหตุผล
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(saved).toHaveLength(1)
|
||||
expect(saved[0].href).toBe('https://files.example/draft.docx?sig=a%2Fb')
|
||||
expect(state.openNotice.value).toContain('Downloads')
|
||||
// ยังยิง ms-word ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังใช้ได้ (Windows / Word รุ่นก่อน)
|
||||
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb')
|
||||
})
|
||||
it('does not auto-download when the app switch was detected (Word เปิดจริง)', async () => {
|
||||
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||
const saved: HTMLAnchorElement[] = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) {
|
||||
saved.push(this)
|
||||
})
|
||||
const state = mount()
|
||||
state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb')
|
||||
window.dispatchEvent(new Event('blur')) // ผู้ใช้สลับไป Word
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(saved).toHaveLength(0)
|
||||
expect(state.openNotice.value).toBeNull()
|
||||
})
|
||||
it('re-click cancels the pending auto-download — only the latest URL is saved once', () => {
|
||||
it('re-click downloads again with the latest URL', () => {
|
||||
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||
const saved: HTMLAnchorElement[] = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) {
|
||||
|
|
@ -148,10 +125,9 @@ describe('version open lifecycle', () => {
|
|||
})
|
||||
const state = mount()
|
||||
state.openDocxUrl('https://files.example/first.docx')
|
||||
vi.advanceTimersByTime(2000)
|
||||
state.openDocxUrl('https://files.example/second.docx') // resetOpen ยกเลิก timer อันเก่า
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(saved).toHaveLength(1)
|
||||
expect(saved[0].href).toBe('https://files.example/second.docx')
|
||||
state.openDocxUrl('https://files.example/second.docx') // resetOpen เคลียร์ notice เดิมแล้วดาวน์โหลดใหม่
|
||||
expect(saved).toHaveLength(2)
|
||||
expect(saved[1].href).toBe('https://files.example/second.docx')
|
||||
expect(state.fallbackUrl.value).toBe('https://files.example/second.docx')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue