# 文件读取与上传签名接入及发布 本次保护 `/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` 执行以下命令可为并列的五个项目生成匹配的本地配置: ```bash 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`。** 本地运行可以设置: ```bash 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 连接,无末尾换行: ```text 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`,不拦截。 后台会截获本系统文件 `` 链接,通过已有 Blob 下载工具处理;H5/小程序富文本文件链接改走图片预览或下载后打开本地文件。复制出来的接口地址不再承诺可直接在浏览器打开。Flutter 原生下载、Feign 等没有浏览器 Fetch Metadata 的请求仍执行签名/旧 Dart 规则;不支持这些请求头的旧浏览器同样不能靠本规则识别。这项规则用于限制普通浏览器导航,不能防止脚本携带有效签名并模拟请求,也不能阻止已经获准显示的图片被保存。 ## 旧 mobile H5 的图片来源兼容 旧 H5 无需重新构建或更新。在更新后的后端配置中增加: ```yaml file-access: public-image-referer-prefixes: - https://www.pjzhhq.cn/mobile/ - https://yyfb.idw.jinhua.gov.cn:6443/ ``` 只对 GET/HEAD 图片读取生效:请求带有单个 `Referer`,其协议、主机、有效端口与配置匹配时,按以下两种范围判断: - 带目录的配置(如 `/mobile/`)仍要求 Referer 路径位于该目录下,目录必须以 `/` 结尾。 - 根来源配置(如 `https://yyfb.idw.jinhua.gov.cn:6443/`,末尾 `/` 可省略)允许该来源下所有页面,包括浏览器跨域策略只发送根来源的请求。端口必须匹配;未写端口的 HTTPS 表示 443,不会匹配 6443。根来源不能再限制到某个应用目录。 命中后,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,代理不得忽略该差异复用放行响应。 根来源支持需要新版 `pjcloud-common-core` 和 UPMS 一起构建发布。建议将这份图片来源白名单放在 UPMS 自己的配置中;若通过共享配置下发给其他启用文件访问配置的服务,这些服务也必须先更新公共依赖,旧版校验类会拒绝根来源。此改动不要求更新 H5 或更换任何密钥。 ## 上传身份与文件类型 现有登录鉴权及 `@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 解码后的字节: ```text 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`;钱包、硬件导出/日志/取证及发票盖章通过相同签名读取 | | 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:`、本地临时文件不加本协议签名。复制访问链接是临时访问地址;编辑、存储文件引用使用原始值。 ### Java 图片合成等内部读取 文件中心图片使用 `RemoteFileService.getFile(fileName)`,每次调用由专属 Feign 拦截器生成 `service` 签名,并通过服务发现访问 UPMS。业务字段如果保存的是完整或相对文件链接,先用 `FileAccessUrl.fileName(source)` 提取原始文件名: ```java String fileName = FileAccessUrl.fileName(source); if (fileName == null) throw new IllegalArgumentException("不是文件中心引用"); try (Response response = remoteFileService.getFile(fileName)) { if (response.status() < 200 || response.status() >= 300 || response.body() == null) { throw new IOException("文件读取失败"); } try (InputStream input = response.body().asInputStream()) { BufferedImage image = ImageIO.read(input); // 校验 image 非空后合成。 } } ``` 提取工具支持原始文件名、`/sys-file/oss/file`、`/admin/sys-file/oss/file` 及带网关前缀的 `/admin/sys-file/oss/file` 链接;查询中的文件名只解码一次,重复、缺失或非法文件名拒绝处理。链接中的主机和旧签名不参与内部调用,始终读取当前环境的 UPMS,不会向原链接主机发送 `service` 凭据。 `WalletRechargeInvoiceServiceImpl.stamp` 已按此方式读取底图。因此 `http://127.0.0.1:9999/admin/sys-file/oss/file?...`、历史域名链接和相对链接都不再依赖 `IMAGE_BASE_URL` 或公网来源列表。非文件中心图片保留原有 HTTP 读取方式,相对静态资源仍使用 `IMAGE_BASE_URL`。调用方和 UPMS 必须加载相同的 `clients.service` 活动密钥;本次修复需要重新构建并发布 Wallet 及更新后的 common-core,无需为本机地址扩大 `allowed-origins`。 旧终端兼容只接受完整 `Dart/<版本> (dart:io)` UA,且扩展名在 jpg/jpeg/png/gif/webp/bmp/svg/ico 内。完全没有签名参数才可走兼容分支;错误或过期签名不会回退。APK、日志、文档不凭 Dart UA 放行。 ## 发布和回滚 1. 在配置中心准备匹配的环境配置,保持 `mode: observe` 和 `legacy-dart.enabled: true`。发布 UPMS、integrated,以及包含文件调用的 wallet、hardware、canteen 等服务和统一公共依赖。只更新 UPMS 会遗漏内部读取。 2. 发布后台、H5、小程序,并提供携带生产配置的新 Flutter 包。小程序保留原文件域名,真机检查 request/downloadFile 域名配置、图片预览和打开文档。 3. 至少观察一个完整业务日,确认登录前 Logo/背景、人员照片、商品/菜品、富文本、附件下载、导出、票据和旧设备更新链路。旧设备必须实测图片 UA 和 Android 原生 APK 下载。 4. 通过验收后设置 `mode: enforce`。按当前配置中心机制刷新或重启实例,逐个确认配置已生效;旧网页需刷新,小程序需加载新版本。 5. 根据设备版本台账确认覆盖后再关 `legacy-dart.enabled`。没有自动失效日,短期没有旧请求不能证明设备已全部升级。 6. 遗漏影响业务时切回 `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 需等设备覆盖后才移除。不要用重新运行生成脚本代替轮换流程。 ## 本地验证命令 三个前端分别执行: ```bash 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 定向回归: ```bash 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,FileAccessUrlTest,WalletRechargeInvoiceFileAccessTest,WalletManageBatchExportTest,WalletLedgerBatchExportTest,HardBatchExportFileTest,HardBatchExportQueryTest \ -Dsurefire.failIfNoSpecifiedTests=false ``` Flutter 使用项目 FVM 3.27.4: ```bash 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 推断视为旧安装包验收。