From 81ffd2dab830ae75cd1ba69257506ff1bb6ed41f Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 29 Oct 2026 18:47:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=8A=A0=E5=AF=86=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=88=B7=E6=96=B0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 8 +- src/sheep/utils/file-access-core.ts | 9 +- src/sheep/utils/file-access.ts | 31 ++++-- tests/file-refresh.test.cjs | 163 ++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 tests/file-refresh.test.cjs diff --git a/.env b/.env index d042ff0..977e789 100644 --- a/.env +++ b/.env @@ -17,13 +17,13 @@ VITE_PWD_ENC_KEY='pigxpigxpigxpigx' VITE_OAUTH2_APP_CLIENT='mini:mini' # --------------------------- start 开发环境 start ---------------------------------- -VITE_APP_DEV_SERVICE = https://www.pjzhhq.cn/api +# VITE_APP_DEV_SERVICE = https://www.pjzhhq.cn/api # VITE_APP_DEV_SERVICE = http://localhost:9999 -# VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc +VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc # VITE_IMAGE_DEV_PATH = http://localhost:9999 -# VITE_IMAGE_DEV_PATH = https://dzc-test-1.fy.takin.cc/api -VITE_IMAGE_DEV_PATH = https://www.pjzhhq.cn/api +VITE_IMAGE_DEV_PATH = https://dzc-test-1.fy.takin.cc +# VITE_IMAGE_DEV_PATH = https://www.pjzhhq.cn/api # ----------------------------- end 开发环境 end ------------------------------------ # --------------------------- start 生产环境 start ---------------------------------- diff --git a/src/sheep/utils/file-access-core.ts b/src/sheep/utils/file-access-core.ts index 3092202..5602e7d 100644 --- a/src/sheep/utils/file-access-core.ts +++ b/src/sheep/utils/file-access-core.ts @@ -124,9 +124,14 @@ export function createFileAccess(options: FileAccessOptions) { if (!Number.isFinite(serverTime) || serverTime <= 0) return offset = serverTime + roundTripMs / 2 - now() if (Number.isFinite(ttlSeconds) && ttlSeconds > 300 && ttlSeconds <= 86400) ttl = ttlSeconds - cache.clear() + // url() checks cached timestamps against the corrected clock. Preserve usable + // signatures so routine calibration does not change every image's src. } - return { uploadHeaders, uploadTarget, url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, clear: () => cache.clear(), ttl: () => ttl } + function invalidate(source?: string | null) { + const parsed = parse(source) + if (parsed) cache.delete(parsed.unsigned) + } + return { uploadHeaders, uploadTarget, url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, invalidate, clear: () => cache.clear(), ttl: () => ttl } } const unescapeAttribute = (s: string) => s.replace(/&/gi, '&').replace(/"/gi, '"').replace(/&#(?:39|x27);/gi, "'").replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n); return code <= 0x10ffff ? String.fromCodePoint(code) : ''; }) diff --git a/src/sheep/utils/file-access.ts b/src/sheep/utils/file-access.ts index 53b9167..1e0c080 100644 --- a/src/sheep/utils/file-access.ts +++ b/src/sheep/utils/file-access.ts @@ -14,6 +14,7 @@ let timer: ReturnType | undefined let pending: Promise | undefined let lastSync = 0 const retried = new Map() +const retrying = new Map>() export function fileUrl(source?: string | null): string { void fileAccessRevision.value try { return client.url(source) } catch { return '' } @@ -47,21 +48,27 @@ export function startFileAccess() { void refreshFileAccess() } export function stopFileAccess() { if (timer) clearInterval(timer); timer = undefined } -export async function retryFileAccess(source?: string | null) { +export async function retryFileAccess(source?: string | null): Promise { if (!isProtectedFile(source)) return false - const raw = originalFileUrl(source), previous = retried.get(raw) || 0 + const raw = resolveFileUrl(originalFileUrl(source)), previous = retried.get(raw) || 0 + const active = retrying.get(raw) + if (active) return active if (Date.now() - previous < 30 * 60 * 1000) return false if (retried.size >= 2048) retried.clear() retried.set(raw, Date.now()) - const previousUrl = fileUrl(source) - await refreshFileAccess(true) - client.clear() - if (fileUrl(source) === previousUrl) { - await new Promise(resolve => setTimeout(resolve, 1100)) - client.clear() - } - fileAccessRevision.value++ - return true + const task = (async () => { + const previousUrl = fileUrl(source) + await refreshFileAccess(true) + client.invalidate(source) + if (fileUrl(source) === previousUrl) { + await new Promise(resolve => setTimeout(resolve, 1100)) + client.invalidate(source) + } + fileAccessRevision.value++ + return true + })() + retrying.set(raw, task) + try { return await task } finally { retrying.delete(raw) } } export function installFileAccess(app: any) { if (typeof uni.addInterceptor === 'function') { @@ -112,7 +119,7 @@ export function downloadFile(options: any) { success: async (response: any) => { if (response.statusCode === 200) { options.success?.(response); options.complete?.(response); return } if (retry && response.statusCode === 403 && isProtectedFile(options.url)) { - await refreshFileAccess(true); client.clear(); run(false); return + await refreshFileAccess(true); client.invalidate(options.url); run(false); return } const error = { errMsg: '文件下载失败', statusCode: response.statusCode } options.fail?.(error); options.complete?.(error) diff --git a/tests/file-refresh.test.cjs b/tests/file-refresh.test.cjs new file mode 100644 index 0000000..a9d4fc7 --- /dev/null +++ b/tests/file-refresh.test.cjs @@ -0,0 +1,163 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const Module = require('node:module') +const ts = require('typescript') +const vue = require('vue') +const { parse, compileTemplate } = require('vue/compiler-sfc') +const root = path.resolve(__dirname, '..') +const raw = file => '/admin/sys-file/oss/file?fileName=' + file +const settle = async () => { await new Promise(setImmediate); await vue.nextTick() } +const compile = source => ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText + +function runtime() { + const realNow = Date.now, previousUni = global.uni + const clock = { now: 1793232000000, offset: 0, requests: 0, offline: false, network: undefined } + const downloads = [], downloadStatuses = [] + Date.now = () => clock.now + global.uni = { + request(options) { + clock.requests++ + queueMicrotask(() => clock.offline ? options.fail(new Error('offline')) : options.success({ statusCode: 200, + data: { data: { serverTime: String(clock.now + clock.offset), ttlSeconds: '1800' } } })) + }, + downloadFile(options) { + downloads.push(options.url) + queueMicrotask(() => options.success({ statusCode: downloadStatuses.shift() ?? 200, tempFilePath: 'local-file' })) + return { abort() {} } + }, + addInterceptor() {}, onNetworkStatusChange(handler) { clock.network = handler }, + previewImage(options) { return options }, + } + const filename = path.join(root, 'src/sheep/utils/file-access.ts') + const corePath = path.join(path.dirname(filename), 'file-access-core.ts') + const core = new Module(corePath, module); core.filename = corePath; core.paths = Module._nodeModulePaths(path.dirname(corePath)) + core._compile(compile(fs.readFileSync(corePath, 'utf8')), corePath) + const env = { VITE_FILE_ACCESS_APP_ID: root.endsWith('pj_mini_app_cloud') ? 'mini' : 'h5', VITE_FILE_ACCESS_KEY_ID: 'v1', + VITE_FILE_ACCESS_KEY: JSON.parse(fs.readFileSync(path.join(root, 'test-vectors/file-access.json'), 'utf8'))[0].key, + VITE_IMAGE_DEV_PATH: 'https://files.example', VITE_APP_DEV_SERVICE: 'https://files.example' } + const loaded = new Module(filename, module); loaded.filename = filename; loaded.paths = core.paths + loaded.require = name => name === './file-access-core' ? core.exports : name === './app' ? { isDevEnv: () => true } : core.require(name) + loaded._compile(compile(fs.readFileSync(filename, 'utf8').replace(/import\.meta\.env/g, '(' + JSON.stringify(env) + ')')), filename) + // Execute the actual filePath wrapper as used by the page templates. + const filePath = path.join(path.dirname(filename), 'file.ts') + const file = new Module(filePath, module); file.filename = filePath; file.paths = core.paths + file.require = name => name === './file-access' ? loaded.exports : core.require(name) + file._compile(compile(fs.readFileSync(filePath, 'utf8')), filePath) + return { api: loaded.exports, file: file.exports, clock, downloads, downloadStatuses, + close() { loaded.exports.stopFileAccess(); Date.now = realNow; global.uni = previousUni } } +} + +function mountPage(env, relative) { + const descriptor = parse(fs.readFileSync(path.join(root, relative), 'utf8')).descriptor + const result = compileTemplate({ source: descriptor.template.content, filename: relative, id: 'file-refresh', + compilerOptions: { isCustomElement: tag => ['view', 'image', 'swiper', 'swiper-item'].includes(tag) } }) + assert.deepEqual(result.errors, []) + const loaded = { exports: {} } + new Function('module', 'exports', 'require', compile(result.code))(loaded, loaded.exports, require) + const patches = [], container = { children: [] } + const renderer = vue.createRenderer({ + createElement: type => ({ type, props: {}, children: [] }), createText: text => ({ text }), createComment: text => ({ text }), + setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.text = text }, + parentNode: node => node.parent, nextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1], + patchProp(node, key, previous, next) { + node.props[key] = next + if (node.type === 'image' && key === 'src' && next && previous !== next) patches.push({ node, url: next }) + }, + insert(node, parent, anchor) { + if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1) + node.parent = parent + const index = anchor ? parent.children.indexOf(anchor) : -1 + parent.children.splice(index < 0 ? parent.children.length : index, 0, node) + }, + remove(node) { if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1) }, + }) + const context = { + SERVICE_IMAGE: { blueBg: raw('background.png') }, filePath: env.file.filePath, + pageHook: vue.reactive({ list: [{ id: 1, name: '食堂1', logoPic: raw('canteen1.png') }, { id: 2, name: '食堂2', logoPic: raw('canteen2.png') }], scroll() {} }), + state: vue.reactive({ swiperList: [{ id: 1, imageUrl: raw('banner.png') }] }), + appMenuListSafe: [{ icon: raw('menu.png'), title: '食堂' }], noticeListSafe: [], groupedNoticeList: [], shouldScroll: false, + crudLayoutRef: vue.ref(), pageRef: vue.ref(), query() {}, toDetail() {}, doMenu() {}, doSwiper() {}, router: { jump() {} }, + } + const app = renderer.createApp({ setup: () => context, render: loaded.exports.render }) + app.component('crud-layout', { props: ['bg'], setup: (props, { slots }) => () => vue.h('view', [ + props.bg ? vue.h('image', { src: env.api.accessFileUrl(props.bg) }) : null, slots.default?.(), + ]) }) + for (const name of ['z-paging', 'tm-tag', 'empty']) app.component(name, { setup: (_, { slots }) => () => vue.h('view', slots.default?.()) }) + env.api.installFileAccess(app) + app.mount(container) + return { patches, context, urls: () => [...new Set(patches.map(patch => patch.node))].map(node => node.props.src), close: () => app.unmount() } +} + +for (const relative of ['src/pages/tabbar/canteen.vue', 'src/pages/root/dashboard.vue'].filter(file => fs.existsSync(path.join(root, file)))) { + test(relative + ': initial calibration, foreground and network recovery do not assign a second image URL', async () => { + const env = runtime(), page = mountPage(env, relative) + try { + const urls = page.urls(), count = page.patches.length + assert.ok(count >= 2) + env.clock.now += 2300 + await env.api.refreshFileAccess(true); await settle() + assert.deepEqual(page.urls(), urls) + assert.equal(page.patches.length, count) + env.clock.now += 61000 + env.api.startFileAccess(); await settle() + assert.deepEqual(page.urls(), urls) + env.clock.now += 2300 + env.clock.network({ isConnected: true }); await settle() + assert.deepEqual(page.urls(), urls) + assert.equal(page.patches.length, count) + } finally { page.close(); env.close() } + }) +} + +test('concurrent failures renew only the failing file while other images keep their signatures', async () => { + const env = runtime(), page = mountPage(env, 'src/pages/tabbar/canteen.vue') + try { + const previous = page.urls(), count = page.patches.length, failing = env.file.filePath(raw('canteen1.png')) + env.clock.now += 1000 + assert.deepEqual(await Promise.all([env.api.retryFileAccess(failing), env.api.retryFileAccess(failing)]), [true, true]) + await settle() + assert.equal(env.clock.requests, 1) + assert.equal(page.patches.length, count + 1) + for (const url of previous.filter(url => !url.includes('canteen1.png'))) assert.ok(page.urls().includes(url)) + const renewed = env.api.fileUrl(raw('canteen1.png')) + assert.notEqual(renewed, failing) + assert.equal(await env.api.retryFileAccess(renewed), false) + assert.equal(env.clock.requests, 1) + } finally { page.close(); env.close() } +}) + +test('an expired attachment download does not refresh unrelated page images', async () => { + const env = runtime(), page = mountPage(env, 'src/pages/tabbar/canteen.vue') + try { + const previous = page.urls(), count = page.patches.length + env.api.fileUrl(raw('report.pdf')); env.clock.now += 1000 + env.downloadStatuses.push(403, 200) + await new Promise((resolve, reject) => env.api.downloadFile({ url: raw('report.pdf'), success: resolve, fail: reject })) + await settle() + assert.equal(env.downloads.length, 2) + assert.notEqual(env.downloads[0], env.downloads[1]) + assert.deepEqual(page.urls(), previous) + assert.equal(page.patches.length, count) + } finally { page.close(); env.close() } +}) + +test('large clock corrections, near expiry and changed business images still generate valid URLs', async () => { + const env = runtime(), page = mountPage(env, 'src/pages/tabbar/canteen.vue') + try { + const previous = env.api.fileUrl(raw('canteen1.png')) + env.clock.offset = 86400000 + await env.api.refreshFileAccess(true); await settle() + const corrected = env.api.fileUrl(raw('canteen1.png')) + assert.notEqual(corrected, previous) + assert.equal(Number(new URL(corrected).searchParams.get('ts')), (env.clock.now + env.clock.offset) / 1000) + env.clock.now += 26 * 60000; env.api.fileAccessRevision.value++; await settle() + assert.notEqual(env.api.fileUrl(raw('canteen1.png')), corrected) + page.context.pageHook.list[0].logoPic = raw('replacement.png'); await settle() + assert.ok(page.urls().some(url => url.includes('replacement.png'))) + assert.equal(page.context.pageHook.list[0].logoPic, raw('replacement.png')) + } finally { page.close(); env.close() } +})