文件读取与上传签名接入及发布
本次保护 /admin/sys-file/oss/file 的全部文件。默认 observe 只记录签名校验结果;只有加载完整配置并切到 enforce 后,普通裸链接才会被拒绝。旧 Dart 图片兼容是独立开关。客户端密钥可提取、UA 可伪造、有效接口地址仍可被模拟请求使用,这是一层应用级校验,不是用户或租户文件授权。
上传入口 /admin/sys-file/upload、/admin/sys-file/inner-upload、/admin/sys-file/qn/upload 同步使用应用签名和服务端文件白名单。上传复用这套配置和密钥,不增加环境密钥组;读取签名不能用于上传。
配置准备
服务端配置见 config.example.yml。密钥为至少 32 字节随机数据的 Base64 字符串,HMAC 使用 Base64 解码后的字节。不要复用 OAuth 客户端信息或密码加密密钥。
在 pj_service_cloud 执行以下命令可为并列的五个项目生成匹配的本地配置:
python3 scripts/generate-file-access-config.py \
--public-base-url https://www.pjzhhq.cn/api \
--allowed-origin https://stgl.pj.gov.cn
按用户最终要求,所有环境共用一套密钥配置,admin/h5/mini/flutter/service/package 各自保留固定密钥;开发、测试、生产不再另建密钥组。只有公网基础地址等部署参数随目标环境填写。脚本统一读取 .file-access/keys.json,重复执行不会更换密钥;旧命令的 --environment 参数仍接受,但不再切换密钥。
迁移时沿用原生产密钥组,避免已构建客户端失效。脚本只更新受管理的文件访问配置字段,保留其他业务环境变量;移除旧 Vite 模式文件中的文件访问字段,避免其覆盖公共配置。旧文件备份位于 Java 项目的 .file-access/migration-backup/,不参与构建。当前活动配置均被 Git 忽略:
首次生成只做一次,其他构建机器和 CI 同步这套现有配置。密钥统一后无需随部署环境重新生成。
| 项目 | 本地文件 | 使用方式 |
|---|---|---|
| Java | .file-access/config.yml |
Nacos 共享配置或 Spring 外部配置 |
| 后台 | .env.local |
所有 Vite 构建模式共用 |
| H5 和小程序 | 各项目的 .env.local |
所有 Vite 构建模式共用 |
| Flutter | .file-access/config.json |
所有构建使用 --dart-define-from-file=.file-access/config.json |
前端的配置项为 VITE_FILE_ACCESS_APP_ID、VITE_FILE_ACCESS_KEY_ID、VITE_FILE_ACCESS_KEY、VITE_FILE_ACCESS_ALLOWED_ORIGINS。Flutter 去掉 VITE_ 前缀。Java 的 clients 包含 admin/h5/mini/flutter/service/package;service 和 package 密钥只放服务端。
自动构建不会取得被 Git 忽略的 .env.local。 后台的 build、build:prod、build:docker 统一使用 --mode prod,自动工具执行 npm run build / pnpm build 时读取 .env.prod。自动构建应从已提交的 .env.prod 或流水线环境变量取得同一组 admin 配置,继续使用既有密钥;不要为流水线重新生成密钥。缺少必要配置时构建会失败,发布流程应停止,不能继续部署上一次留下的 dist。
H5 使用 uni-app 的 production 模式,自动构建配置为该项目的 .env.production,其中 appId 为 h5,复用既有 H5 密钥。build、build:h5 显式使用 uni build -p h5 --mode production;Dockerfile 的 pnpm build:h5 同样读取该文件,默认产物目录为 dist/build/h5。自动构建所需的新配置文件须与源码一起提交;本地 .env.local 仍可用于开发,不作为 CI 输入。
public-base-url 是外部可访问的网关基础地址,可以带 /api,不含 /admin/sys-file/oss/file。现有的图片/API 基础地址继续由各端原有配置决定;allowed-origins / ALLOWED_ORIGINS 只列确实属于本系统的历史域名。上线前必须逐环境核对域名、基础路径、appId、kid 和密钥。相对 /api/admin/sys-file/oss/file 按当前文件基础地址解析,绝对历史地址保留其原有域名和部署路径。
Java 不会自动读取仓库里的 .file-access。 本地运行可以设置:
export SPRING_CONFIG_ADDITIONAL_LOCATION=file:/absolute/path/pj_service_cloud/.file-access/config.yml
通过现有 Nacos 配置体系加载这套 file-access 配置,或把外部配置挂载到各服务并设置上述变量。实际部署路径与公网基础地址应对应目标机器。首次生成默认为 observe,已有配置保留当前模式;显式 --mode 可更新本地生成配置的模式。先检查配置源优先级;config.example.yml 中的环境变量占位符不能代替实际密钥。严格模式要求 admin/mini/flutter/service/package 的有效活动密钥及公网基础地址。h5 条目可整体省略;一旦配置该条目,密钥也必须完整。未配置 H5 不会自动放行 H5 请求,只有下面明确配置的图片来源可免签。运行中错误配置返回 500,不会降级匿名访问。即使在观察模式,版本签发和服务间读取也需要配置 package/service 密钥。
协议
请求查询参数为 appId、kid、ts、sign、fileName。签名原文按 LF 连接,无末尾换行:
v1
oss-file-read
appId
kid
ts
Base64URL无填充编码的UTF-8文件名
ts 是 Unix 秒;sign 是 HMAC-SHA256 的 64 位小写十六进制。fileName 只 URL 解码一次,始终放在最后。后端生成的 APK URL 因此仍以 .apk 结尾,兼容旧终端的扩展名解析。
默认普通签名有效 1800 秒,package 有效 86400 秒,允许签发时间领先最多 300 秒。GET /admin/sys-file/oss/access-info 匿名返回 R.data.serverTime(毫秒)和 ttlSeconds,不包含密钥。各端校准时差并在签名剩余不足 5 分钟时续期;校时失败保留上次时差,不能退回裸链接。传输 URL 缓存在内存里,普通渲染不会每次产生新签名。
重复关键参数、缺少字段、非规范时间戳或非规范签名为 400;未知客户端/密钥、错误签名、过期、时钟越界为 403;文件不存在为 404,数据库或存储读取失败为 500。GET 和 HEAD 共用校验。普通登录 Token 不会绕过校验。文件响应最多私有缓存 5 分钟且不超过剩余签名寿命;错误响应禁止缓存。已经输出部分字节后的网络/存储中断无法可靠改写 HTTP 状态,应按中断响应处理。
block-browser-navigation 默认为 true。严格模式下,浏览器携带 Sec-Fetch-Mode: navigate 或 Sec-Fetch-Dest: document/iframe/frame 的文件请求返回 403,即使签名正确也不放行;覆盖地址栏粘贴、新窗口打开和直接嵌入文件页面。图片、音视频以及 fetch/XHR 下载继续校验签名后访问。响应的 Vary 包含 Sec-Fetch-Mode 和 Sec-Fetch-Dest,防止浏览器把图片缓存复用为文件页面。观察模式仅记录 browser_navigation,不拦截。
后台会截获本系统文件 <a> 链接,通过已有 Blob 下载工具处理;H5/小程序富文本文件链接改走图片预览或下载后打开本地文件。复制出来的接口地址不再承诺可直接在浏览器打开。Flutter 原生下载、Feign 等没有浏览器 Fetch Metadata 的请求仍执行签名/旧 Dart 规则;不支持这些请求头的旧浏览器同样不能靠本规则识别。这项规则用于限制普通浏览器导航,不能防止脚本携带有效签名并模拟请求,也不能阻止已经获准显示的图片被保存。
旧 mobile H5 的图片来源兼容
旧 H5 无需重新构建或更新。在更新后的后端配置中增加:
file-access:
public-image-referer-prefixes:
- https://www.pjzhhq.cn/mobile/
只对 GET/HEAD 图片读取生效:请求带有单个 Referer,其协议、主机、端口与前缀匹配,且路径位于 /mobile/ 下时,JPG/JPEG/PNG/GIF/WebP/BMP/SVG/ICO 可以不带 appId 或签名读取。页面 URL 的 #/ 不会随 HTTP Referer 发送。来源命中的图片请求整体免签,因此旧页面携带的过期签名也不阻止加载。重复关键参数、非法文件名和浏览器页面导航仍优先拒绝;PDF、APK、日志等文件及上传接口不享受此例外。
这是独立的来源白名单,保留或省略 clients.h5 都可使用;不能仅根据 appId=h5 放行。新版本允许省略整个 H5 客户端配置,但空密钥、错误密钥配置仍报错,其他五类客户端的必要配置保持严格检查。若删除共享配置中的 H5 条目,所有加载旧版公共配置类的服务也需要同步更新;保留现有 H5 配置同样可以启用来源兼容。
配置默认空列表;显式清空列表即可恢复图片签名要求。必须部署新 UPMS 和对应的公共依赖后再启用配置,不能用旧后端只删除 H5 密钥。Referer 必须由浏览器和代理保留;没有 Referer 的匿名请求仍返回 403。Referer 可被脚本模拟,这是一层兼容性的来源检查。成功响应私有缓存最多 5 分钟,Vary 额外区分 Referer,代理不得忽略该差异复用放行响应。
上传身份与文件类型
现有登录鉴权及 @Inner 内部调用限制保留。每次 POST multipart 上传只允许一个名为 file 的文件部分,通过下列请求头传递身份,不把上传凭据放进 URL 或业务字段:
| 请求头 | 内容 |
|---|---|
X-File-App-Id |
admin/h5/mini/flutter/service,package 不可上传 |
X-File-Key-Id |
现有 kid,如 v1 |
X-File-Timestamp |
Unix 秒级时间戳 |
X-File-Nonce |
16~64 位字母、数字、_、-,每次请求重新生成 |
X-File-Signature |
HMAC-SHA256 的 64 位小写十六进制 |
签名原文按 LF 连接,无末尾换行;密钥仍使用 Base64 解码后的字节:
v1
oss-file-upload
POST
target
appId
kid
ts
nonce
target 分别为 upload、inner-upload、qn-upload,三种签名不能互换。inner-upload 仅接受 service。签名用于应用身份和本次上传操作校验,文件内容由服务端独立检查。默认有效 300 秒,允许客户端时间领先最多 60 秒;复用现有匿名校时接口。严格模式下 Redis 原子 SET NX 消耗 nonce,重复请求返回 403;Redis 或配置不可用返回 500,不自动放行。不增加 Dart UA 上传兼容。
file-access.upload 默认策略如下,省略配置时 Java 使用相同默认值:
| 文件类别 | 白名单后缀 |
|---|---|
| 图片 | jpg/jpeg/png/gif/webp/bmp/ico/svg |
| 文档和安装包 | pdf/apk/doc/docx/xls/xlsx/ppt/pptx |
| 文本和压缩包 | txt/csv/zip |
| 音视频 | mp4/mov/webm/avi/mp3/wav/m4a/aac/ogg/amr/flac |
| 仅内部上传额外允许 | log/gz,供终端日志使用 |
默认单文件最大 200 MiB,仍受现有网关、反向代理和 Servlet 更低限制约束。SVG 最大 1 MiB,只接受静态内容,拒绝脚本、事件、外部引用和实体。服务层在写存储或文件记录前统一校验后缀、文件头及适用的图片尺寸/ZIP 容器结构;APK 检查二进制 AndroidManifest,Office 新格式检查对应容器入口。存储后缀和 MIME 由服务端规范化,不信任客户端 Content-Type。这里的格式检查不等于病毒或文档宏扫描。
JPEG、PNG、GIF、BMP、WebP 位图按实际文件头识别和校验:声明后缀与实际格式均须在对应白名单中,符合条件时使用实际格式保存,不修改图片字节。例如名为 .png 的合法 WebP 保存为 .webp、MIME 为 image/webp。声明为 EXE/PDF/SVG 等其他类别的文件不通过这个规则转成图片,SVG 和文档继续执行各自的严格检查。后台单图、多图及七牛图片上传默认支持 WebP。
缺少或错误身份、过期、重放为 403;重复或畸形头、多个文件部分为 400;不支持类型或内容不符为 415;超过本策略大小为 413。错误响应禁止缓存。文件类型检查在 observe 下仍执行,观察模式只放宽应用签名准入。监控为 file.upload.requests,标签 outcome/app/mode,不记录密钥、签名和登录 Token。
后台 Axios、通用 el-upload 和富文本上传,H5/小程序的 uni.uploadFile 拦截器,以及 Flutter HttpManage 为本系统上传路径生成新请求头;导入接口和第三方上传地址不追加本协议。RemoteFileService 专属 Feign 拦截器为每次内部上传生成 service 签名。上传成功返回值和保存的文件引用继续为稳定原始地址。
已有读取严格模式的环境,先发布客户端及调用文件服务的 Java 服务,再发布 UPMS。 旧 UPMS 忽略新增头;新版 UPMS 在 enforce 下会拒绝没有上传签名的旧网页/服务请求。Java 调用方须随公共依赖一同更新,避免只升级 UPMS。旧 Flutter 当前通过硬件业务接口上传取证和日志,再由 Java 调用文件中心,这条链路无需旧设备立即升级;不要把这一结论扩展到其他直接调用文件中心的旧客户端。必要时切回 observe 恢复旧身份请求,文件白名单仍然生效。
入口与数据约束
| 部分 | 接入位置与行为 |
|---|---|
| Java 文件入口 | FileAccessGuard,SysFileController,SysFileServiceImpl;租户跳过只作用于文件查询并恢复原状态 |
| Java 内部读取 | RemoteFileService 保留 getFile 调用,通过编码桥接和专属 Feign 拦截器签名,保留文件名中的字面 %xx;钱包、硬件导出/日志/取证通过相同签名读取;票据 URLConnection 单独适配 |
| APK | 终端 /app-version/ad/page 和 /app/last-version 返回对象使用 package 签名,后台列表/编辑和数据库仍为原始值;独立静态 APK 地址保持不变 |
| 后台 | src/utils/file-access*.ts;FileImage 保留已加载画面,未加载的懒图片继续续期,打开/切换预览时取有效签名;失败重试仅刷新当前文件,不清空全局签名缓存;上传回显、素材库、头像、视频、版本复制和下载均已接入;KKFileView 在编码之前签名源地址 |
| 后台编辑器 | 只更新编辑器 DOM 的资源属性,不替换整篇文档;输出 HTML 剥离临时签名,续期不进入编辑历史 |
| H5 和小程序 | src/sheep/utils/file-access*.ts、filePath/parseHtml、共享预览下载工具及各页面资源属性;公共算法不依赖 DOM/window;富文本图片样式保留 |
| Flutter | FileAccess、AppNetworkTool、SignedNetworkImage;主副屏独立初始化,缓存键用规范化原始地址,传输 URL 用签名 |
| Flutter APK 重试 | 原生下载收到 403 时重新查询相同 ID、版本码、平台和应用的已发布版本,最多重试一次,不能换成另一个版本继续安装 |
业务对象、表单、上传返回值、SQLite 人脸 imagePath、失败记录和 referenceImageUrl 继续保留原始引用。本次没有数据库迁移、SQLite 升级或清空人脸库。新业务展示统一调用文件工具,不要在 API 响应拦截器里递归改写业务字符串。
第三方 URL、静态资源、data:、blob:、本地临时文件不加本协议签名。复制访问链接是临时访问地址;编辑、存储文件引用使用原始值。
旧终端兼容只接受完整 Dart/<版本> (dart:io) UA,且扩展名在 jpg/jpeg/png/gif/webp/bmp/svg/ico 内。完全没有签名参数才可走兼容分支;错误或过期签名不会回退。APK、日志、文档不凭 Dart UA 放行。
发布和回滚
- 在配置中心准备匹配的环境配置,保持
mode: observe和legacy-dart.enabled: true。发布 UPMS、integrated,以及包含文件调用的 wallet、hardware、canteen 等服务和统一公共依赖。只更新 UPMS 会遗漏内部读取。 - 发布后台、H5、小程序,并提供携带生产配置的新 Flutter 包。小程序保留原文件域名,真机检查 request/downloadFile 域名配置、图片预览和打开文档。
- 至少观察一个完整业务日,确认登录前 Logo/背景、人员照片、商品/菜品、富文本、附件下载、导出、票据和旧设备更新链路。旧设备必须实测图片 UA 和 Android 原生 APK 下载。
- 通过验收后设置
mode: enforce。按当前配置中心机制刷新或重启实例,逐个确认配置已生效;旧网页需刷新,小程序需加载新版本。 - 根据设备版本台账确认覆盖后再关
legacy-dart.enabled。没有自动失效日,短期没有旧请求不能证明设备已全部升级。 - 遗漏影响业务时切回
observe并保留诊断;这会重新允许普通未签名请求。无需数据库回滚,也无需四端同时回退。
代理/CDN 必须把文件路径送到后端校验,保留完整查询参数,不得忽略查询参数缓存文件、强制公共缓存或通过其他公开存储入口绕过该接口。校时接口也必须匿名可达。不要在访问日志中记录完整签名 URL、密钥或登录 Token;可按路径、来源类别和结果计数。
监控 file.access.requests(Prometheus 通常呈现为 file_access_requests_total),标签 outcome/app/mode。结果包括 signed、missing_signature、malformed_signature、invalid_signature、expired、clock_skew、legacy_dart、unknown_client、unknown_key、configuration_error 等。文件查询/存储失败另计 file.access.read.failures。拒绝/兼容日志按结果采样,计数器保留完整次数。
轮换时先在服务端增加新 kid,同时保留旧 kid,再更新客户端构建;服务端主动签发使用 active-kid。Flutter 的旧 kid 需等设备覆盖后才移除。不要用重新运行生成脚本代替轮换流程。
本地验证命令
三个前端分别执行:
node --test tests/file-access.test.cjs tests/file-upload.test.cjs
后台执行 pnpm build:prod,H5 执行 pnpm build:h5,小程序执行 pnpm build:mp-weixin。跨语言固定向量在各项目 test-vectors/file-access.json 和 test-vectors/file-upload.json,其中的密钥是公开测试数据,不能用于部署。配置生成器回归执行 python3 -m unittest discover -s scripts/tests。
Java 定向回归:
mvn -pl pjcloud-upms/pjcloud-upms-biz,pjcloud-integrated/pjcloud-integrated-biz,pjcloud-wallet/pjcloud-wallet-biz,pjcloud-hardware/pjcloud-hardware-biz,pjcloud-canteen/pjcloud-canteen-biz \
-am test \
-Dtest=FileAccessTest,FileReadTest,FileUploadGuardTest,FileUploadPolicyTest,FileUploadControllerTest,AppVersionFileAccessTest,WalletManageBatchExportTest,WalletLedgerBatchExportTest,HardBatchExportFileTest,HardBatchExportQueryTest \
-Dsurefire.failIfNoSpecifiedTests=false
Flutter 使用项目 FVM 3.27.4:
fvm flutter test --no-pub --dart-define-from-file=.file-access/config.json \
test/file_access_test.dart test/file_upload_test.dart test/package_download_test.dart \
test/face_sync_plan_test.dart test/face_sync_pipeline_test.dart \
test/face_sync_failure_store_test.dart test/face_readiness_cache_test.dart \
test/customer_display_balance_test.dart test/customer_display_balance_widget_test.dart
fvm flutter build apk --release --no-pub --dart-define-from-file=.file-access/config.json
验收结果与待现场验证项见 verification.md。不要把本地构建成功或源码中的 Dart UA 推断视为旧安装包验收。