feat:加密图片重复刷新问题
This commit is contained in:
1 parent
44444c3b1b
commit
81ffd2dab8
4 files changed
+193
-18
No files matched your search
@@ -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 ----------------------------------
|
||||
|
||||
@@ -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) : ''; })
|
||||
|
||||
@@ -14,6 +14,7 @@ let timer: ReturnType<typeof setInterval> | undefined
|
||||
let pending: Promise<void> | undefined
|
||||
let lastSync = 0
|
||||
const retried = new Map<string, number>()
|
||||
const retrying = new Map<string, Promise<boolean>>()
|
||||
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<boolean> {
|
||||
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)
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
Reference in new issue
Block a user