hookehuyr

feat(cdn-image): 实现CDN静态图片统一版本缓存管理机制

新增`src/utils/assetUrl.js`工具文件,封装CDN图片版本化处理逻辑
在全局App启动及支付确认页预加载图片版本配置,缓存版本信息到本地
替换支付确认页内硬编码的CDN图片地址,改用工具生成带版本的URL
更新README.md与AGENTS.md文档,说明缓存更新流程与开发约定
......@@ -20,7 +20,7 @@
- `src/hooks/`:较轻量的通用 hooks,目前主要是 `useGo.js` 这类导航辅助。
- `src/api/`:接口封装层。`fn.js` 是统一返回格式与 mock 接入的总入口,`index.js``tabbar.js``wx/pay.js` 分别承接首页、底部导航、微信支付等当前主链路接口;`message.js` 目前更偏旧版原生资讯列表/详情演示接口。
- `src/mock/`:本地 mock 体系。目录拆分规则是 `index.js` 统一入口、`modules/` 放 handler、`shared/` 放公共解析能力、`stores/` 放有状态 mock 数据、`fixtures/` 放静态样本。
- `src/utils/`:公共工具层。当前高频核心文件是 `authRedirect.js``request.js``config.js``webview.js``tabbar.js``paySuccessRedirect.js``wechatPay.js`;改动授权、环境、WebView 路由或支付时优先先看这里。若是“小程序内支付成功后该跳到哪里”这类问题,先看 `paySuccessRedirect.js`,不要在页面里各自重复写一份 `app_menu -> data.user.link -> webview-preview / 首页` 判断。
- `src/utils/`:公共工具层。当前高频核心文件是 `authRedirect.js``request.js``config.js``webview.js``tabbar.js``paySuccessRedirect.js``wechatPay.js``assetUrl.js`;改动授权、环境、WebView 路由、支付或 CDN 静态图缓存时优先先看这里。若是“小程序内支付成功后该跳到哪里”这类问题,先看 `paySuccessRedirect.js`,不要在页面里各自重复写一份 `app_menu -> data.user.link -> webview-preview / 首页` 判断。
- `src/stores/`:Pinia 状态目录。当前重点是 `tabbar.js`(底部导航配置)和 `router.js`(授权回跳来源页),其他 store 多为基础能力或历史保留。
- `src/assets/``src/constants/`:分别承接静态资源和常量;其中 `src/constants/` 目前较轻,新增共享枚举或键名时再往这里收。
- `config/`:构建与环境配置目录,`dev.js` / `prod.js` 控制 `API_RUNTIME_ENV`,不要把环境切换逻辑重新分散回页面层。
......@@ -48,6 +48,17 @@ Mock 敶&極嚗src/mock/index.js`
仓库实现上,共享支付能力核心在 `src/composables/useWechatMiniPay.js``src/api/index.js`;调试入口在 `src/pages/pay-test/index.vue`,正式确认页在 `src/pages/pay-confirm/index.vue`,H5 桥接页在 `src/pages/pay-bridge/index.vue`。另一条是历史保留的通用支付封装,位于 `src/utils/wechatPay.js``src/api/wx/pay.js`,通过 `pay_id` 调用 `/srv/?a=icbc_pay_wxamp`。当前若处理支付问题,默认先看 `order_id -> useWechatMiniPay -> /srv/?a=pay` 这条主链路;其中 `f``client_id` 由请求层公共参数统一补齐,不要在单个支付接口 URL 上重复手写。若没有用户明确点名 `pay_id``wechatPay.js``src/api/wx/pay.js`,暂时不要把排查范围扩到这条历史链路,也不要顺手改它。修改支付逻辑时务必先确认当前页面接的是哪一条接口链路,不要混用 `order_id``pay_id`,也不要在未拿到后端有效支付参数时直接调用 `requestPayment`。涉及 H5/WebView 唤起支付时,应优先保持 `pages/pay-bridge` 的桥接职责与返回参数约定稳定;涉及小程序内直接支付时,应优先保持 `pages/pay-confirm` 的“展示金额 -> 用户点击 -> 调用共享支付能力 -> 成功后按 `app_menu.data.user.link``webview-preview`,无 `user` 则回首页”职责单一。这个成功跳转规则当前集中在 `src/utils/paySuccessRedirect.js`,如果以后还有别的小程序内支付入口要复用同样落点,优先复用它,不要在各页面里各写一版菜单判断。无论改哪条链路,都需要区分成功、取消、失败三类状态,并至少在微信开发者工具或真机中验证一次“授权状态检查 -> 拉起支付 -> 返回结果展示/回跳”的流程。
## CDN 静态图片缓存约定
仓库里如果有直接写死的 `https://cdn.ipadbiz.cn/jls_weapp/images/` 图片地址,不要再在页面里裸写完整 URL,也不要每张图各自手工拼时间戳。当前约定是统一走 `src/utils/assetUrl.js`:应用启动时会预加载 `https://cdn.ipadbiz.cn/jls_weapp/version.json`,并给本地写死的 CDN 图片自动补上 `?v=<image_version>`
这里的边界要守住:
- 当前这套版本参数机制只用于“仓库里本地写死的 CDN 图片地址”,主要目的是 CDN 覆盖同名图后,让小程序端也能跟着拿到新图。
- 接口动态返回的图片 URL 默认先不要动,除非用户明确要求把某一类接口图也接入同一套版本机制。
- 新增这类本地写死图片时,优先用 `getVersionedImageAssetByName('xxx.png')`,不要继续手抄完整 CDN 地址。
- 如果只是替换同名图片内容,标准流程应该是:上传同名图片到 CDN -> 手动刷新图片 CDN 缓存 -> 更新 `version.json` 里的 `image_version` -> 再刷新 `version.json` 自己的 CDN 缓存。
- 如果后续发现某个页面用了 `jls_weapp/images` 下的本地写死图但没有走 `assetUrl.js`,应视为漏接入,优先补到这一层,而不是在页面里临时各写一版缓存参数。
## 提交与合并请求规范
当前 Git 历史以简短中文提交为主,例如 `初始化觉林寺小程序项目`。后续提交信息也请保持中文、简洁、祈使语气,并聚焦单一改动。提交 PR 时请附上:变更背景与解决方案、关联任务或问题、影响的页面或模块、手工验证步骤;涉及界面改动时,需补充截图或录屏。
......@@ -55,4 +66,4 @@ Mock 敶&極嚗src/mock/index.js`
本仓库默认使用中文沟通与书写。今后新增或修改的说明性文字请统一使用中文,包括但不限于文档、注释说明、提交说明、PR 描述、变更摘要和协作备注;如必须保留英文术语,请同时提供中文语义,避免只写英文描述。
## 安全与配置提示
不要提交真实的 AppID、令牌或生产环境域名。首次接手项目时,请优先检查 `src/utils/config.js``config/dev.js``config/prod.js``project.config.json`。授权与支付测试环境当前会复用既有后端配置,调整域名、`client_id`、支付接口地址或 WebView 跳转地址前,必须先确认不会影响 `openid`、会话续期和微信支付参数生成。除非明确要重构认证链路,否则不要随意删除或绕过 `src/pages/auth/index` 认证页;同理,除非明确要废弃本地 mock 联调能力,否则不要把 `API_RUNTIME_ENV``src/mock/index.js``src/mock/modules/` 里的统一分发能力改回页面内局部开关。
不要提交真实的 AppID、令牌或生产环境域名。首次接手项目时,请优先检查 `src/utils/config.js``config/dev.js``config/prod.js``project.config.json`。授权与支付测试环境当前会复用既有后端配置,调整域名、`client_id`、支付接口地址或 WebView 跳转地址前,必须先确认不会影响 `openid`、会话续期和微信支付参数生成。若调整 CDN 静态图片更新策略,也要同时确认 `https://cdn.ipadbiz.cn/jls_weapp/version.json` 仍然返回 `{ "image_version": "..." }` 这类结构,避免 `assetUrl.js` 读不到版本号。除非明确要重构认证链路,否则不要随意删除或绕过 `src/pages/auth/index` 认证页;同理,除非明确要废弃本地 mock 联调能力,否则不要把 `API_RUNTIME_ENV``src/mock/index.js``src/mock/modules/` 里的统一分发能力改回页面内局部开关。
......
......@@ -174,6 +174,45 @@ API_RUNTIME_ENV: '"production"'
详细约定见 [src/mock/README.md](/Users/huyirui/program/itomix/jls_weapp/src/mock/README.md)
### 1.3 CDN 静态图片缓存更新
如果仓库里的页面直接写死了 `https://cdn.ipadbiz.cn/jls_weapp/images/` 下的图片地址,不建议每次换图都改代码重新提审,也不要在每个页面里临时手工拼时间戳。当前项目已经约定用 `version.json` 统一控制这类本地写死 CDN 图片的缓存版本。
当前固定地址:
- `https://cdn.ipadbiz.cn/jls_weapp/version.json`
当前文件结构:
```json
{
"image_version": "20260515-1"
}
```
运行方式:
- 应用启动时会预加载这份 `version.json`
- 本地写死的 `https://cdn.ipadbiz.cn/jls_weapp/images/xxx.png` 会自动变成 `https://cdn.ipadbiz.cn/jls_weapp/images/xxx.png?v=<image_version>`
- 这样当 CDN 覆盖同名图片后,只要版本值变化,小程序就会把它当成新资源重新拉取
当前边界:
- 这套机制只默认作用于“仓库里本地写死的 CDN 图片地址”
- 接口动态返回的图片 URL 默认不处理,避免把首页/后台内容图也一起卷进缓存策略
开发约定:
- 新增这类本地写死图片时,优先通过 `src/utils/assetUrl.js` 里的 `getVersionedImageAssetByName('xxx.png')` 接入
- 不要继续在页面里裸写完整 CDN 地址后再各自补 `?v=` 或时间戳
当你要替换同名图片时,推荐流程是:
1. 上传同名图片覆盖 CDN 旧文件
2. 手动刷新图片 CDN 缓存
3. 修改 `version.json` 里的 `image_version`
4. 再刷新 `version.json` 自己的 CDN 缓存
### 2. 定义 API 接口
编辑 `src/api/index.js`,添加您的业务接口:
......
......@@ -3,12 +3,17 @@ import { createPinia } from 'pinia'
import './utils/polyfill'
import './app.less'
import { saveCurrentPagePath, hasAuth, silentAuth, navigateToAuth } from '@/utils/authRedirect'
import { preloadImageAssetVersion } from '@/utils/assetUrl'
import { useTabbarStore } from '@/stores/tabbar'
const pinia = createPinia()
const App = createApp({
async onLaunch(options) {
preloadImageAssetVersion().catch((error) => {
console.error('预加载图片版本配置失败:', error)
})
const path = options?.path || ''
const query = options?.query || {}
......
<template>
<view class="pay-confirm-page">
<view class="hero-panel">
<view class="hero-panel" :style="hero_panel_style">
<text class="page-title">常住随用</text>
<view class="amount-card">
<view class="amount-card" :style="amount_card_style">
<text class="amount-label">支付金额:</text>
<view class="amount-value-group">
<text class="amount-value">{{ display_amount }}</text>
......@@ -19,6 +19,7 @@
<button
class="pay-button"
:style="pay_button_style"
hover-class="pay-button-hover"
:loading="pay_loading"
:disabled="!order_id || pay_loading"
......@@ -34,6 +35,7 @@
import { computed, ref, watch } from 'vue'
import { useLoad } from '@tarojs/taro'
import { useWechatMiniPay } from '@/composables/useWechatMiniPay'
import { getVersionedImageAssetByName, preloadImageAssetVersion } from '@/utils/assetUrl'
import { redirectAfterPaySuccess } from '@/utils/paySuccessRedirect'
const order_id = ref('')
......@@ -53,6 +55,15 @@ const display_amount = computed(() => {
})
const has_amount = computed(() => display_amount.value !== '--')
const hero_panel_style = computed(() => ({
background: `url('${getVersionedImageAssetByName('bgg@2x.png')}') center top / 100% 100% no-repeat`,
}))
const amount_card_style = computed(() => ({
background: `url('${getVersionedImageAssetByName('money_bg@2x.png')}') center / 100% 100% no-repeat`,
}))
const pay_button_style = computed(() => ({
background: `url('${getVersionedImageAssetByName('btnn@2x.png')}') center / 100% 100% no-repeat`,
}))
watch(last_result_text, (value) => {
if (!pay_loading.value) {
......@@ -107,6 +118,10 @@ const handlePay = async () => {
}
useLoad((options) => {
preloadImageAssetVersion().catch((error) => {
console.error('支付确认页预加载图片版本配置失败:', error)
})
order_id.value = String(options?.order_id || '').trim()
amount.value = String(options?.amount || options?.money || '').trim()
......@@ -131,7 +146,6 @@ useLoad((options) => {
min-height: 612rpx;
padding: 132rpx 48rpx 88rpx;
box-sizing: border-box;
background: url('https://cdn.ipadbiz.cn/jls_weapp/images/bgg@2x.png') center top / 100% 100% no-repeat;
}
.page-title {
......@@ -149,7 +163,6 @@ useLoad((options) => {
min-height: 145rpx;
padding: 0 40rpx;
box-sizing: border-box;
background: url('https://cdn.ipadbiz.cn/jls_weapp/images/money_bg@2x.png') center / 100% 100% no-repeat;
display: flex;
align-items: center;
justify-content: space-between;
......@@ -221,7 +234,6 @@ useLoad((options) => {
font-size: 34rpx;
font-weight: 500;
color: #fff6eb;
background: url('https://cdn.ipadbiz.cn/jls_weapp/images/btnn@2x.png') center / 100% 100% no-repeat;
&::after {
border: 0;
......@@ -230,7 +242,6 @@ useLoad((options) => {
&[disabled] {
opacity: 0.72;
color: #fff6eb;
background: url('https://cdn.ipadbiz.cn/jls_weapp/images/btnn@2x.png') center / 100% 100% no-repeat;
}
}
......
import { ref } from 'vue'
import Taro from '@tarojs/taro'
const IMAGE_CDN_PREFIX = 'https://cdn.ipadbiz.cn/jls_weapp/images/'
const ASSET_VERSION_URL = 'https://cdn.ipadbiz.cn/jls_weapp/version.json'
const ASSET_VERSION_STORAGE_KEY = 'jls_weapp_image_asset_version'
const ASSET_VERSION_FIELD = 'image_version'
const normalizeValue = (value) => String(value || '').trim()
const getStoredAssetVersion = () => {
try {
return normalizeValue(Taro.getStorageSync(ASSET_VERSION_STORAGE_KEY))
} catch (error) {
console.error('读取图片版本缓存失败:', error)
return ''
}
}
export const imageAssetVersion = ref(getStoredAssetVersion())
let assetVersionRequestPromise = null
const saveAssetVersion = (version) => {
const normalizedVersion = normalizeValue(version)
imageAssetVersion.value = normalizedVersion
try {
if (normalizedVersion) {
Taro.setStorageSync(ASSET_VERSION_STORAGE_KEY, normalizedVersion)
return
}
Taro.removeStorageSync(ASSET_VERSION_STORAGE_KEY)
} catch (error) {
console.error('缓存图片版本失败:', error)
}
}
const parseAssetVersionResponse = (data) => {
if (typeof data === 'string') {
try {
const parsedData = JSON.parse(data)
return normalizeValue(parsedData?.[ASSET_VERSION_FIELD])
} catch (error) {
return ''
}
}
return normalizeValue(data?.[ASSET_VERSION_FIELD])
}
const appendOrReplaceQueryParam = (url, key, value) => {
if (!value) {
return url
}
const hashIndex = url.indexOf('#')
const hash = hashIndex >= 0 ? url.slice(hashIndex) : ''
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url
const queryPattern = new RegExp(`([?&])${key}=[^&#]*`)
if (queryPattern.test(baseUrl)) {
return `${baseUrl.replace(queryPattern, `$1${key}=${encodeURIComponent(value)}`)}${hash}`
}
const joiner = baseUrl.includes('?') ? '&' : '?'
return `${baseUrl}${joiner}${key}=${encodeURIComponent(value)}${hash}`
}
export const buildImageCdnUrl = (fileName = '') => `${IMAGE_CDN_PREFIX}${normalizeValue(fileName)}`
export const shouldUseVersionedImageAsset = (url) => normalizeValue(url).startsWith(IMAGE_CDN_PREFIX)
export const getVersionedImageAssetUrl = (url) => {
const normalizedUrl = normalizeValue(url)
if (!shouldUseVersionedImageAsset(normalizedUrl)) {
return normalizedUrl
}
const version = normalizeValue(imageAssetVersion.value)
if (!version) {
return normalizedUrl
}
return appendOrReplaceQueryParam(normalizedUrl, 'v', version)
}
export const getVersionedImageAssetByName = (fileName = '') => (
getVersionedImageAssetUrl(buildImageCdnUrl(fileName))
)
export const preloadImageAssetVersion = async (force = false) => {
if (!force && assetVersionRequestPromise) {
return assetVersionRequestPromise
}
assetVersionRequestPromise = Taro.request({
url: appendOrReplaceQueryParam(ASSET_VERSION_URL, '_t', String(Date.now())),
method: 'GET',
enableCache: false,
})
.then((response) => {
const version = parseAssetVersionResponse(response?.data)
if (version) {
saveAssetVersion(version)
}
return version
})
.catch((error) => {
console.error('加载图片版本配置失败:', error)
return imageAssetVersion.value
})
.finally(() => {
assetVersionRequestPromise = null
})
return assetVersionRequestPromise
}