feat:加密图片重复刷新问题

This commit is contained in:
dzc committed 2026-10-29 18:47:31 +08:00
1 parent 44444c3b1b
commit 81ffd2dab8
4 files changed
+193 -18

No files matched your search

+4 -4
View File
@@ -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 ----------------------------------
+7 -2
View File
@@ -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(/&amp;/gi, '&').replace(/&quot;/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) : ''; })
+19 -12
View File
@@ -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)
+163
View File
@@ -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() }
})