fix:图片访问

fix:权限修改
This commit is contained in:
dzc committed 2026-10-29 14:28:38 +08:00
1 parent 710e4cfb90
commit bbde6cc87f
56 files changed
+2671 -271

No files matched your search

+2 -6
View File
@@ -62,9 +62,5 @@ gen_code
### node ###
node_modules
### AI 辅助工具目录 ###
.v2c/
.video_agent/
.mimosa/
.cloudbase/
.zcode/
# Local file-access credentials
.file-access/
+129
View File
@@ -0,0 +1,129 @@
# 文件访问签名接入与发布
本次保护 `/admin/sys-file/oss/file` 的全部文件。默认 `observe` 只记录签名校验结果;只有加载完整配置并切到 `enforce` 后,普通裸链接才会被拒绝。旧 Dart 图片兼容是独立开关。客户端密钥可提取、UA 可伪造、有效接口地址仍可被模拟请求使用,这是一层应用级校验,不是用户或租户文件授权。
## 配置准备
服务端配置见 `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` 密钥只放服务端。
`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` 中的环境变量占位符不能代替实际密钥。严格模式缺少任一客户端的有效活动密钥或公网基础地址会启动失败;运行中错误配置会返回 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`,不拦截。
后台会截获本系统文件 `<a>` 链接,通过已有 Blob 下载工具处理;H5/小程序富文本文件链接改走图片预览或下载后打开本地文件。复制出来的接口地址不再承诺可直接在浏览器打开。Flutter 原生下载、Feign 等没有浏览器 Fetch Metadata 的请求仍执行签名/旧 Dart 规则;不支持这些请求头的旧浏览器同样不能靠本规则识别。这项规则用于限制普通浏览器导航,不能防止脚本携带有效签名并模拟请求,也不能阻止已经获准显示的图片被保存。
## 入口与数据约束
| 部分 | 接入位置与行为 |
| --- | --- |
| 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 放行。
## 发布和回滚
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
```
后台执行 `pnpm build:prod`,H5 执行 `pnpm build:h5`,小程序执行 `pnpm build:mp-weixin`。跨语言固定向量在各项目 `test-vectors/file-access.json`,其中的密钥是公开测试数据,不能用于部署。
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,AppVersionFileAccessTest,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/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 推断视为旧安装包验收。
+32
View File
@@ -0,0 +1,32 @@
# Load through Nacos shared config or Spring additional-location. No usable keys are committed.
file-access:
mode: observe
block-browser-navigation: true
ttl-seconds: 1800
package-ttl-seconds: 86400
future-skew-seconds: 300
public-base-url: https://files.example.com/api
allowed-origins:
- https://legacy-files.example.com
legacy-dart:
enabled: true
extensions: [jpg, jpeg, png, gif, webp, bmp, svg, ico]
clients:
admin:
active-kid: v1
keys: {v1: '${FILE_ACCESS_ADMIN_V1:}'}
h5:
active-kid: v1
keys: {v1: '${FILE_ACCESS_H5_V1:}'}
mini:
active-kid: v1
keys: {v1: '${FILE_ACCESS_MINI_V1:}'}
flutter:
active-kid: v1
keys: {v1: '${FILE_ACCESS_FLUTTER_V1:}'}
service:
active-kid: v1
keys: {v1: '${FILE_ACCESS_SERVICE_V1:}'}
package:
active-kid: v1
keys: {v1: '${FILE_ACCESS_PACKAGE_V1:}'}
+94
View File
@@ -0,0 +1,94 @@
# 五端文件签名验证记录
验证日期:2026-10-29。范围为本地五个项目的实现、定向回归、构建和已登录后台页面检查。未发布服务、修改 Nacos、开启生产拦截、更新真实设备、执行数据库迁移或提交 Git。
## 已完成
| 验证 | 结果 |
| --- | --- |
| Java 请求级与业务回归 | 41 项通过,0 失败;覆盖 UPMS 文件校验/读取、版本地址、钱包和硬件导出,同时编译相关公共模块及 canteen |
| 后台自动测试 | 14 项通过,包含文件协议与已有部门导出测试 |
| H5 文件自动测试 | 14 项通过 |
| 小程序文件自动测试 | 14 项通过 |
| 跨语言协议 | Java、三端 TypeScript、Dart 使用相同公开向量;校验 UTF-8 文件名、秒级时间戳、Base64URL 原文和 HMAC 结果 |
| URL 与运行时 | 覆盖中文、空格、加号、百分号编码、历史域名、相对/绝对地址、已有 `/api` 前缀、重复签名、第三方地址;前端测试执行实际运行时包装器,H5/小程序测试执行富文本预览组件的 JS 方法;Java 使用真实 SpringMvcContract/Feign 客户端与内存传输验证文件名字面 `%2F/%2B`、重复拦截和参数顺序 |
| 后台生产构建 | `pnpm build:prod` 通过 |
| H5 构建 | `pnpm build:h5` 通过 |
| 微信小程序构建 | `pnpm build:mp-weixin` 通过 |
| Flutter 定向回归 | 56 项通过;含签名、安装包一次重试、人脸同步计划/流水线/失败记录/缓存、主副屏余额回归 |
| Android Release | FVM Flutter 3.27.4 构建通过,使用 `.file-access/production.json`;APK 约 101.9 MB |
| 新增后台 TypeScript 工具 | 独立严格类型检查通过 |
| 配置生成器 | 在临时目录重复运行,确认幂等、保留已有文件;开发/测试/生产配置和各客户端密钥相互独立 |
| 密钥与原有变更 | 扫描五个项目全部已跟踪及未忽略候选文件,未发现生成密钥泄漏;后台原有补丁可反向校验,Java 原有补丁除追加忽略规则产生的上下文差异外可反向校验 |
| Diff 检查 | 保留原有 CRLF 文件格式;使用 `git -c core.whitespace=cr-at-eol diff --check` 通过 |
APK 位置(相对本仓库):`../pj_supermarket_cloud/build/app/outputs/flutter-apk/app-release.apk`。本次构建尚未在真实终端安装验证;发布前须核对包内环境与服务端配置一致。
Java 定向测试及各端构建命令见 [接入与发布说明](README.md)。本机详细日志保存在 `/tmp/pj-file-access-work/`,属于临时验证资料,不随仓库发布。
## 静态检查与既有问题
使用任务开始前保存的补丁还原临时代码基线,与当前代码运行同一检查后比较:
| 项目 | 当前结果与基线比较 |
| --- | --- |
| 后台 `vue-tsc` | 6 条既有解析错误,均在 `src/views/admin/system/tenant/index.vue:83`;与基线完全一致 |
| H5 `vue-tsc` | 4 条既有解析错误,在 `src/pages/tabbar/~individualOrder.vue:78-79`;与基线完全一致 |
| 小程序 `vue-tsc` | 17 条既有 TS6504(JS Vue 来源与 allowJs 配置);规范化临时目录路径后与基线完全一致 |
| Flutter `analyze --no-pub` | 69 条提示/警告;忽略因新增代码产生的行号偏移后,诊断集合与基线完全一致,无新增项 |
因此不能把本次结果表述为“全项目类型检查通过”。H5/小程序构建仍有 TM UI 类型导出和循环依赖等构建提示;构建已成功,未在本次跨端文件改造中整理无关依赖。
## 已登录后台检查
使用用户提供的 `http://localhost:8888/` 已登录会话,检查文件管理页:
- 本系统有效图片的缩略图与放大预览加载成功,资源请求带 `appId/kid/ts/sign`,`fileName` 位于最后。
- 实测发现并修复前端 `/api` 前缀被重复保留的问题:当前开发图片服务器不带 `/api`,相对旧路径应转换为 `/admin/sys-file/oss/file`。配置含 `/api` 的部署仍保留一次前缀。
- 旧头像的一条原始文件链接返回 HTTP 200、内容长度为 0,属于当前运行后端/存储的既有异常。新 Java 读取代码已覆盖不存在和存储异常状态,但尚未发布,不能声称现场问题已修复。
- 当前后端匿名 `/admin/sys-file/oss/access-info` 返回 424 鉴权错误,说明运行环境尚未具备本次匿名校时接口;签名客户端会保留本地时间/最近偏移,不降级匿名读取。
- 点击素材下载后未出现页面错误,但浏览器下载事件未在等待窗口内返回。浏览器工具自动安全检查拒绝打开 `chrome://downloads/`(仅允许 HTTP/HTTPS),未采用其他方式绕过此限制,**下载文件实际落盘未确认**。
当前运行后端仍接受裸文件链接。上述浏览器结果证明前端签名与展示链路可用,不代表新后端严格校验已在线生效。
## 发布前仍需完成
1. 核对每个环境的公网基础地址、历史域名、密钥和活动 kid;Java 不会自动加载 `.file-access/*.yml`,须通过 Nacos 或 `SPRING_CONFIG_ADDITIONAL_LOCATION` 加载。先以 `observe` 发布 UPMS、integrated 和所有实际调用文件服务的模块。
2. 用发布后的后端确认匿名校时可达,执行 GET/HEAD、错误参数、错误/过期签名、旧 Dart 兼容、404/500、私有缓存及服务间读取联调。
3. 在真实旧 Flutter 安装包上确认图片请求 UA、图片显示及 Android 原生 APK 下载。新版包确认主副屏、缓存命中、人脸重复同步/离线恢复、取证、打印、手动/自动更新;不能用本地源码或单元测试替代设备验收。
4. 微信真机验证登录前 Logo/背景、长时间停留和恢复前台、图片预览、文件下载及打开文档;核对微信文件域名配置。后台与 H5 补充对应实际业务操作验收。
5. 至少观察一个完整业务日,确认已知入口无遗漏后再切 `enforce`;根据设备版本台账确认升级覆盖后再关闭旧 Dart 图片兼容。
当前默认保持 `observe` 与旧 Dart 图片兼容开启。回滚方法是恢复 `observe` 并保留诊断日志;业务引用和人脸记录未持久化临时签名,无需数据库回滚。
## 后台图片闪烁修复补充
2026-10-29 修复后台单张图片失败重试清空全局签名缓存的问题。重试只刷新当前文件,同一文件的并发重试合并;普通校时保留仍有效的签名。新增公共 `FileImage`,已加载图片在分钟检查、签名续期和恢复前台时保持当前画面;未加载的懒图片继续更新有效地址,放大预览在打开或切换图片时取有效签名。业务引用改变时正常加载新图。
- 后台定向回归 19 项通过,包含真实组件 setup、模板与 Vue 生命周期测试;模拟停留超过 30 分钟、恢复前台、失败重试、预览和懒加载。
- 新组件与文件工具的定向 `vue-tsc` 检查通过;后台生产构建通过。
- 已登录商户页实测:原先已加载的 24 张图片跨分钟检查后地址不变,其余慢图片随后加载完成,共 30 张图片成功、无加载占位;放大预览成功且请求带签名。
## 浏览器直接打开文件地址限制补充
2026-10-29 新增默认开启的 `block-browser-navigation`。严格模式下,携带浏览器页面导航 Fetch Metadata 的文件请求,即使签名有效也返回 403;页面图片、音视频、fetch/XHR 下载和无浏览器 Metadata 的原生/服务请求继续按原签名规则处理。成功响应 `Vary` 区分导航与子资源请求;拒绝响应 `no-store`。观察模式仅记录 `browser_navigation`。
- Java 文件访问/读取请求级测试共 12 项通过,覆盖签名正确的导航、GET/HEAD 在读取存储前拒绝、图片与下载放行、原生包下载、Dart UA 不绕过导航限制、开关和观察模式。
- 后台 20 项、H5 16 项、小程序 16 项定向测试通过。覆盖文件链接的页面内下载、H5 富文本与普通链接拦截、图片预览、本地文档打开及第三方链接保持原行为。
- 后台生产、H5、微信小程序构建通过;后台文件组件/工具定向类型检查通过。
- 配置生成器在临时目录验证:新配置含 `block-browser-navigation: true`,默认仍为 `observe`,没有修改实际运行环境配置。
这项补充尚未部署到文件服务器,不能把本地请求级测试表述为该公网地址已返回 403。上线时代理须保留 `Sec-Fetch-Mode/Dest` 和响应 `Vary`;用新签名或无旧缓存的窗口验证。后端无法收回浏览器已经取得的文件内容。旧浏览器可能不提供 Fetch Metadata,脚本也能模拟请求,因此这项规则不等于逐用户授权或防止已显示图片被保存。
## 最终调整:统一一套密钥与严格模式联调
用户明确要求所有环境使用同一套密钥,取代本文前面记录的开发/测试/生产独立密钥方案。沿用已配置的原生产密钥组,未生成新密钥;各 appId 仍使用各自固定密钥,service/package 密钥仍只存在服务端。三个前端统一使用 `.env.local`,Java 使用 `.file-access/config.yml`,Flutter 使用 `.file-access/config.json`。旧模式文件的签名字段已清理并备份,旧命令 `--environment` 不再切换密钥。
此前严格模式白图的真实原因是:浏览器按开发密钥签名,后端配置的是原生产密钥,实际响应为 `invalid_signature`。本次已通过本地 Nacos REST API 修正 `application-dev.yml`,保留 `enforce`、浏览器导航拦截和旧 Dart 兼容,公网基础地址对齐测试网关。Nacos 完整原配置保存在本机私有临时备份,发布时使用 MD5 比较避免覆盖并发编辑,生产服务配置未修改。
- 使用真实页面中的同一个签名 URL 联调:页面图片请求 200、应用下载请求 200、浏览器导航请求 403,原因为 `browser_navigation`。
- 实际后台重新加载后,首轮观察为 30 张图片中的 24 张加载成功,其余 6 张仍在慢请求中;随后浏览器自动化连接中断,未把该轮全部图片的最终状态作为已验证结果。签名已与统一密钥注册表一致。
- 真实 Vite `loadEnv` 验证三个项目共 18 个模式组合,全部使用统一密钥;生成器迁移、重复执行、原密钥保留与错误配置拒绝共 3 项测试通过。
- 后台生产、H5、微信小程序构建通过;Flutter 使用统一配置执行签名和安装包定向测试,8 项通过。
后续构建与配置准备以 README 中的统一路径为准。本文前面提到的分环境配置路径属于历史验证记录,不再作为当前操作指引。
@@ -115,6 +115,7 @@ public class AppUserController {
*/
@Operation(summary = "分页查询", description = "分页查询")
@GetMapping("/page")
@HasPermission("app_appuser_page")
public R getAppUserPage(Page page, AppUserDTO appUserDTO) {
return R.ok(appUserService.getUsersWithRolePage(page, appUserDTO));
}
@@ -0,0 +1,16 @@
package com.bsd.pjcloud.common.core.config;
import com.bsd.pjcloud.common.core.file.FileAccessProperties;
import com.bsd.pjcloud.common.core.file.FileAccessSigner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(FileAccessProperties.class)
public class FileAccessConfiguration {
@Bean
@ConditionalOnMissingBean
public FileAccessSigner fileAccessSigner(FileAccessProperties properties) { return new FileAccessSigner(properties); }
}
@@ -0,0 +1,80 @@
package com.bsd.pjcloud.common.core.file;
import lombok.Data;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.*;
@Data
@ConfigurationProperties(prefix = "file-access")
public class FileAccessProperties implements InitializingBean {
private String mode = "observe";
private long ttlSeconds = 1800;
private long packageTtlSeconds = 86400;
private long futureSkewSeconds = 300;
private boolean blockBrowserNavigation = true;
private String publicBaseUrl = "";
private List<String> allowedOrigins = new ArrayList<>();
private Map<String, Client> clients = new LinkedHashMap<>();
private LegacyDart legacyDart = new LegacyDart();
@Data
public static class Client {
private String activeKid = "v1";
private Map<String, String> keys = new LinkedHashMap<>();
}
@Data
public static class LegacyDart {
private boolean enabled = true;
private Set<String> extensions = new HashSet<>(List.of("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "ico"));
}
public boolean enforcing() { return "enforce".equals(mode); }
public long ttl(String appId) { return "package".equals(appId) ? packageTtlSeconds : ttlSeconds; }
public String key(String appId, String kid) {
Client client = clients.get(appId);
return client == null ? null : client.getKeys().get(kid);
}
@Override
public void afterPropertiesSet() { validate(); }
/** Also called at the gate, so an invalid refreshed configuration cannot open access. */
public void validate() {
if (!Set.of("observe", "enforce").contains(mode) || ttlSeconds <= 300 || packageTtlSeconds <= 300
|| futureSkewSeconds < 0 || futureSkewSeconds > 300) {
throw new IllegalStateException("Invalid file-access policy");
}
if (enforcing() || !publicBaseUrl.isBlank()) {
try {
java.net.URI uri = java.net.URI.create(publicBaseUrl);
if (uri.getHost() == null || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()))
|| uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null) {
throw new IllegalArgumentException();
}
} catch (IllegalArgumentException e) { throw new IllegalStateException("Invalid file-access.public-base-url"); }
}
if (enforcing()) {
for (String app : List.of("admin", "h5", "mini", "flutter", "service", "package")) {
Client client = clients.get(app);
if (client == null || !client.getActiveKid().matches("[a-zA-Z0-9_-]{1,32}")
|| !validKey(client.getKeys().get(client.getActiveKid()))) {
throw new IllegalStateException("Missing file-access key for " + app);
}
}
}
clients.forEach((app, client) -> client.getKeys().forEach((kid, key) -> {
if (key != null && !key.isBlank() && (!kid.matches("[a-zA-Z0-9_-]{1,32}") || !validKey(key))) {
throw new IllegalStateException("Invalid file-access key configuration for " + app);
}
}));
}
private static boolean validKey(String key) {
try { return key != null && Base64.getDecoder().decode(key).length >= 32; }
catch (IllegalArgumentException e) { return false; }
}
}
@@ -0,0 +1,38 @@
package com.bsd.pjcloud.common.core.file;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HexFormat;
/** Versioned wire protocol shared with the browser and Dart clients. */
public final class FileAccessSignature {
private FileAccessSignature() { }
public static String canonical(String fileName, String appId, String kid, long timestamp) {
return String.join("\n", "v1", "oss-file-read", appId, kid, Long.toString(timestamp),
Base64.getUrlEncoder().withoutPadding().encodeToString(fileName.getBytes(StandardCharsets.UTF_8)));
}
public static String sign(String fileName, String appId, String kid, long timestamp, String base64Key) {
try {
byte[] key = Base64.getDecoder().decode(base64Key);
if (key.length < 32) throw new IllegalArgumentException("File access key must contain at least 32 bytes");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(canonical(fileName, appId, kid, timestamp)
.getBytes(StandardCharsets.UTF_8)));
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException("Cannot calculate file access signature", e);
}
}
public static boolean matches(String expected, String actual) {
return actual != null && actual.matches("[0-9a-f]{64}")
&& MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), actual.getBytes(StandardCharsets.US_ASCII));
}
}
@@ -0,0 +1,89 @@
package com.bsd.pjcloud.common.core.file;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.*;
/** Signs only this installation's file-center URLs, never arbitrary remote URLs. */
public class FileAccessSigner {
private final FileAccessProperties properties;
private final Clock clock;
public FileAccessSigner(FileAccessProperties properties) { this(properties, Clock.systemUTC()); }
public FileAccessSigner(FileAccessProperties properties, Clock clock) { this.properties = properties; this.clock = clock; }
public Map<String, String> parameters(String fileName, String appId) {
FileAccessProperties.Client client = properties.getClients().get(appId);
if (client == null) throw new IllegalStateException("Missing file-access signer configuration for " + appId);
String kid = client.getActiveKid();
String key = properties.key(appId, kid);
if (key == null || key.isBlank()) throw new IllegalStateException("Missing file-access signer key for " + appId);
long ts = clock.instant().getEpochSecond();
Map<String, String> result = new LinkedHashMap<>();
result.put("appId", appId);
result.put("kid", kid);
result.put("ts", Long.toString(ts));
result.put("sign", FileAccessSignature.sign(fileName, appId, kid, ts, key));
result.put("fileName", fileName); // Legacy APK clients require .apk at the end of the URL.
return result;
}
public String signUrl(String source, String appId) {
if (source == null || source.isBlank()) return source;
String value = source.trim();
String fileName;
String endpoint;
if (!value.contains("/") && !value.contains("?") && !value.contains(":")) {
fileName = value;
endpoint = base() + "/admin/sys-file/oss/file";
} else {
URI uri;
try { uri = URI.create(value); } catch (IllegalArgumentException e) { return source; }
if (uri.isAbsolute() && !owned(uri)) return source;
if (uri.getRawAuthority() != null && !uri.isAbsolute()) return source;
String path = uri.getPath();
String configuredPath = properties.getPublicBaseUrl().isBlank() ? "" : URI.create(properties.getPublicBaseUrl()).getPath().replaceAll("/+$", "") + "/admin/sys-file/oss/file";
if (!Set.of("/sys-file/oss/file", "/admin/sys-file/oss/file", "/api/admin/sys-file/oss/file").contains(path) && !path.equals(configuredPath)) return source;
List<String> names = new ArrayList<>();
if (uri.getRawQuery() != null) for (String pair : uri.getRawQuery().split("&")) {
String[] parts = pair.split("=", 2);
if (decode(parts[0]).equals("fileName")) names.add(decode(parts.length == 2 ? parts[1] : ""));
}
if (names.size() != 1 || names.get(0).isBlank()) throw new IllegalArgumentException("Invalid file-center URL");
fileName = names.get(0);
if (uri.isAbsolute()) endpoint = value.split("\\?", 2)[0];
else endpoint = base() + "/admin/sys-file/oss/file";
}
StringJoiner query = new StringJoiner("&");
parameters(fileName, appId).forEach((key, valuePart) -> query.add(key + "=" + encode(valuePart)));
return endpoint + "?" + query;
}
private String base() {
String base = properties.getPublicBaseUrl().replaceAll("/+$", "");
if (!base.matches("https?://[^/]+.*")) throw new IllegalStateException("Configure file-access.public-base-url");
return base;
}
private boolean owned(URI uri) {
if (!Set.of("http", "https").contains(Objects.toString(uri.getScheme(), "").toLowerCase(Locale.ROOT)) || uri.getUserInfo() != null) return false;
List<String> origins = new ArrayList<>(properties.getAllowedOrigins());
origins.add(properties.getPublicBaseUrl());
return origins.stream().filter(s -> s != null && !s.isBlank()).anyMatch(s -> {
URI allowed = URI.create(s);
return uri.getScheme().equalsIgnoreCase(allowed.getScheme())
&& uri.getHost() != null && uri.getHost().equalsIgnoreCase(allowed.getHost())
&& port(uri) == port(allowed);
});
}
private static int port(URI uri) {
return uri.getPort() >= 0 ? uri.getPort() : ("https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80);
}
private static String decode(String value) { return URLDecoder.decode(value, StandardCharsets.UTF_8); }
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); }
}
@@ -4,4 +4,4 @@ com.bsd.pjcloud.common.core.config.MessageSourceConfiguration
com.bsd.pjcloud.common.core.config.RestTemplateConfiguration
com.bsd.pjcloud.common.core.config.ImmutableConfiguration
com.bsd.pjcloud.common.core.util.SpringContextHolder
com.bsd.pjcloud.common.core.config.FileAccessConfiguration
@@ -48,6 +48,11 @@ public class TenantContextHolder {
THREAD_LOCAL_TENANT_SKIP_FLAG.set(Boolean.TRUE);
}
/** Restore only the skip flag without clearing the caller's tenant. */
public void setTenantSkip(boolean skip) {
THREAD_LOCAL_TENANT_SKIP_FLAG.set(skip);
}
/**
* 获取TTL中的租户ID
* @return
@@ -2,7 +2,7 @@
`ExcelStyleTokens` 提供全局默认样式,配置前缀为 `excel.export-style`,可放在服务配置或 Nacos 公共配置中。未配置的值使用代码默认值,表头与正文可以分别设置字体、字号、加粗、文字颜色、背景颜色、行高、对齐、换行和缩进。
当前仅 `/admin/user/export-async` 接入。原同步用户导出和其他业务导出的样式不受影响,已有模板无需修改。
`/admin/user/export-async` 和日志导出(`/admin/log/export`、`/admin/log/export-async`)已接入。原同步用户导出及未显式接入处理器的业务导出不受影响,已有模板无需修改。
```yaml
excel:
@@ -47,10 +47,12 @@ excel:
列宽优先级:配置中的 `column-widths` > 业务默认列宽 > `column-width`。用户导出已按编号、手机、邮箱、部门、角色和日期等字段分别设置列宽,不扫描全部明细来计算列宽。长文本保持原值,默认正文不换行;需要多行显示时可同时增加 `body.row-height` 并开启 `body.wrap-text`。开启交替行底纹时,偶数数据行使用 `alternate-row-color`,其余数据行使用 `body.background-color`。
后续导出可显式注册处理器来复用样式(本次不接入其他接口):
其他导出可显式注册处理器来复用样式:
```java
builder.registerWriteHandler(new ExcelExportStyleHandler(excelConfig.getExportStyle()));
```
每次导出创建独立处理器,在提交任务时固定当前 token。处理器只支持 XLSX,缓存工作簿中的字体与单元格样式,条纹按实际行号计算,适用于分批流式写入。现有转换器、单元格类型、数据格式及导出列保持原样。固定格式模板应继续使用自身样式,不自动注册此处理器。
日志导出沿用原有列,支持日志类型、创建时间范围、应用标识及原接口的日志编号筛选。每批按编号倒序读取 200 条,仅首批统计总数,后续使用编号游标;新增的更大编号不会挤偏后续批次。旧 GET 接口仍直接返回附件,新 POST `/admin/log/export-async` 返回任务信息,使用 `/admin/async-task/progress` 和命名空间 `admin:export:log` 查询进度。两个接口均要求 `sys_log_export` 权限,超出单工作表行数限制时提示缩小筛选范围。
@@ -112,7 +112,7 @@ public class RemoteServiceImpl implements IRemoteService {
Long parentId = checkParentDto.getParentId();
List<Long> deptIdList = checkParentDto.getDeptIdList();
// 查询子级包括自己
List<SysDept> allDept = deptService.getAllDept(null).getData();
List<SysDept> allDept = deptService.getInnerAllDept(null).getData();
List<SysDept> childrenDeptList = DataUtil.selectChildrenByDept(parentId, allDept);
List<Long> childrenDeptIdList = childrenDeptList.stream().map(SysDept::getDeptId).collect(Collectors.toList());
@@ -148,7 +148,7 @@ public class RemoteServiceImpl implements IRemoteService {
Long childId = checkChildDto.getChildId();
List<Long> deptIdList = checkChildDto.getDeptIdList();
// 查询父级包括自己
List<SysDept> allDept = deptService.getAllDept(null).getData();
List<SysDept> allDept = deptService.getInnerAllDept(null).getData();
List<SysDept> parentDeptList = DataUtil.selectParentByDept(childId, allDept);
List<Long> parentDeptIdList = parentDeptList.stream().map(SysDept::getDeptId).collect(Collectors.toList());
parentDeptIdList.remove(childId);
@@ -18,6 +18,16 @@ import org.springframework.stereotype.Service;
*/
@Service
public class AppVersionServiceImpl extends ServiceImpl<AppVersionMapper, AppVersionEntity> implements AppVersionService {
@org.springframework.beans.factory.annotation.Autowired
private com.bsd.pjcloud.common.core.file.FileAccessSigner fileAccessSigner;
private <T extends AppVersionEntity> T signDownload(T version) {
if (version == null) return null;
@SuppressWarnings("unchecked")
T response = (T) cn.hutool.core.bean.BeanUtil.copyProperties(version, version.getClass());
response.setDownloadUrl(fileAccessSigner.signUrl(version.getDownloadUrl(), "package"));
return response;
}
/**
* 分页查询
@@ -38,7 +48,7 @@ public class AppVersionServiceImpl extends ServiceImpl<AppVersionMapper, AppVers
.like(ObjectUtil.isNotEmpty(dto.getPlatform()), AppVersionEntity::getPlatform, dto.getPlatform())
.orderByDesc(AppVersionEntity::getVersionCode)
.last("limit 1");
return baseMapper.selectOne(queryWrapper);
return signDownload(baseMapper.selectOne(queryWrapper));
}
/**
@@ -50,7 +60,9 @@ public class AppVersionServiceImpl extends ServiceImpl<AppVersionMapper, AppVers
*/
@Override
public Page<AppVersionVo> terminalPageList(Page page, AppVersionDto dto) {
return baseMapper.terminalPageList(page, dto);
Page<AppVersionVo> result = baseMapper.terminalPageList(page, dto);
result.setRecords(result.getRecords().stream().map(this::signDownload).toList());
return result;
}
/**
@@ -48,6 +48,9 @@
<where>
a.del_flag = '0'
and a.status = '1'
<if test="dto.id != null">
and a.id = #{dto.id}
</if>
<if test="dto.appUniqueCode != null and dto.appUniqueCode != ''">
and a.app_unique_code = #{dto.appUniqueCode}
</if>
@@ -0,0 +1,45 @@
package com.bsd.pjcloud.integrated.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.file.FileAccessProperties;
import com.bsd.pjcloud.common.core.file.FileAccessSigner;
import com.bsd.pjcloud.integrated.dto.appmodule.AppVersionDto;
import com.bsd.pjcloud.integrated.mapper.appmodule.AppVersionMapper;
import com.bsd.pjcloud.integrated.service.impl.appmodule.AppVersionServiceImpl;
import com.bsd.pjcloud.integrated.vo.appmodule.AppVersionVo;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class AppVersionFileAccessTest {
@Test void onlyTerminalResponsesAreSignedAndMapperEntitiesStayRaw() {
var properties = new FileAccessProperties();
properties.setPublicBaseUrl("https://files.example/api");
var key = new FileAccessProperties.Client();
key.getKeys().put("v1", "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=");
properties.getClients().put("package", key);
var mapper = mock(AppVersionMapper.class);
var service = new AppVersionServiceImpl();
ReflectionTestUtils.setField(service, "baseMapper", mapper);
ReflectionTestUtils.setField(service, "fileAccessSigner", new FileAccessSigner(properties));
var version = new AppVersionVo();
version.setId(9L); version.setDownloadUrl("/admin/sys-file/oss/file?fileName=terminal.apk");
String raw = version.getDownloadUrl();
var page = new Page<AppVersionVo>(); page.setRecords(List.of(version));
when(mapper.terminalPageList(any(), any())).thenReturn(page);
var response = service.terminalPageList(new Page<>(), new AppVersionDto()).getRecords().get(0);
assertNotSame(version, response);
assertEquals(raw, version.getDownloadUrl());
assertTrue(response.getDownloadUrl().contains("appId=package"));
assertTrue(response.getDownloadUrl().endsWith("fileName=terminal.apk"));
var adminPage = new Page<AppVersionVo>(); adminPage.setRecords(List.of(version));
when(mapper.pagList(any(), any())).thenReturn(adminPage);
assertEquals(raw, service.pageList(new Page<>(), new AppVersionDto()).getRecords().get(0).getDownloadUrl());
when(mapper.selectOne(any())).thenReturn(version);
assertTrue(service.lastVersion(new AppVersionDto()).getDownloadUrl().contains("appId=package"));
assertEquals(raw, version.getDownloadUrl());
}
}
@@ -0,0 +1,29 @@
package com.bsd.pjcloud.admin.api.config;
import com.bsd.pjcloud.common.core.file.FileAccessSigner;
import feign.RequestInterceptor;
import org.springframework.context.annotation.Bean;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
/** Intentionally not component-scanned: applies only to RemoteFileService. */
public class FileAccessFeignConfiguration {
@Bean
public RequestInterceptor fileAccessRequestInterceptor(FileAccessSigner signer) {
return template -> {
if (!"GET".equals(template.method()) || !"/sys-file/oss/file".equals(template.path())) return;
Collection<String> names = template.queries().get("fileName");
if (names == null || names.size() != 1) throw new IllegalArgumentException("Exactly one fileName is required");
String encodedName = names.iterator().next();
String name = URLDecoder.decode(encodedName, StandardCharsets.UTF_8);
template.query("fileName", (String[]) null);
signer.parameters(name, "service").forEach((key, value) -> {
template.query(key, (String[]) null);
// Keep Feign's resolved encoding, and place fileName last like every other client.
template.query(key, key.equals("fileName") ? encodedName : value);
});
};
}
}
@@ -0,0 +1,14 @@
package com.bsd.pjcloud.admin.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/** 部门导出沿用页面的部门名称筛选。 */
@Data
@Schema(description = "部门导出查询条件")
public class DeptExportDTO {
@Schema(description = "部门名称")
private String deptName;
}
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.admin.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime;
@@ -91,6 +92,7 @@ public class SysLogDTO {
/**
* 创建时间区间 [开始时间,结束时间]
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime[] createTime;
/**
@@ -0,0 +1,16 @@
package com.bsd.pjcloud.admin.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 日志导出沿用列表的类型、时间区间和应用筛选,兼容旧接口的编号筛选。 */
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "日志导出查询条件")
public class SysLogExportDTO extends SysLogDTO {
@Schema(description = "日志编号,为空时导出全部符合筛选条件的日志")
private Long[] ids;
}
@@ -45,12 +45,12 @@ public interface RemoteClientDetailsService {
@GetMapping("/client/getClientDetailsById/{clientId}")
R<SysOauthClientDetails> getClientDetailsById(@PathVariable("clientId") String clientId);
/**
* 查询全部客户端
* @return R
*/
@NoToken
@GetMapping("/client/list")
R<List<SysOauthClientDetails>> listClientDetails();
// /**
// * 查询全部客户端
// * @return R
// */
// @NoToken
// @GetMapping("/client/list")
// R<List<SysOauthClientDetails>> listClientDetails();
}
@@ -38,12 +38,6 @@ import java.util.Map;
@FeignClient(contextId = "remoteDeptService", value = ServiceNameConstants.UPMS_SERVICE)
public interface RemoteDeptService {
/**
* 获取所有部门接口
*/
@GetMapping("/dept/list")
R<List<SysDept>> getAllDept(@RequestParam("parentDeptId") Long parentDeptId);
/**
* 查询所有部门包括被删除的
* 被删除的部门名有(被删除)标注
@@ -1,6 +1,7 @@
package com.bsd.pjcloud.admin.api.feign;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.admin.api.config.FileAccessFeignConfiguration;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
import feign.Response;
@@ -12,17 +13,26 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* 文件读取
*
* @author pjcloud
* @date 2024/3/14
*/
@FeignClient(contextId = "remoteFileService", value = ServiceNameConstants.UPMS_SERVICE)
@FeignClient(contextId = "remoteFileService", value = ServiceNameConstants.UPMS_SERVICE, configuration = FileAccessFeignConfiguration.class)
public interface RemoteFileService {
/** Callers supply the original filename, including any literal percent sequences. */
default Response getFile(String fileName) {
// Feign preserves existing %xx escapes, so encode the raw value before template expansion.
return getFileEncoded(URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"));
}
@GetMapping("/sys-file/oss/file")
Response getFile(@RequestParam String fileName);
Response getFileEncoded(@RequestParam("fileName") String encodedFileName);
@PostMapping(value = "/sys-file/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+5
View File
@@ -204,6 +204,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-wallet-api</artifactId>
@@ -51,67 +51,67 @@ import java.util.List;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SysAuditLogController {
private final SysAuditLogService sysAuditLogService;
private final SysAuditLogService sysAuditLogService;
/**
* 分页查询
* @param page 分页对象
* @param sysAuditLog 审计记录表
* @return
*/
@Operation(summary = "分页查询", description = "分页查询")
@GetMapping("/page")
public R getsysAuditLogPage(@ParameterObject Page page, @ParameterObject SysAuditLog sysAuditLog) {
return R.ok(sysAuditLogService.getAuditsByScope(page, sysAuditLog));
}
/**
* 分页查询
*/
@Operation(summary = "分页查询", description = "分页查询")
@GetMapping("/page")
@HasPermission("sys_audit_page")
public R getsysAuditLogPage(@ParameterObject Page page, @ParameterObject SysAuditLog sysAuditLog) {
return R.ok(sysAuditLogService.getAuditsByScope(page, sysAuditLog));
}
/**
* 通过id查询审计记录表
* @param id id
* @return R
*/
@Operation(summary = "通过id查询", description = "通过id查询")
@GetMapping("/{id}")
public R getById(@PathVariable("id") Long id) {
return R.ok(sysAuditLogService.getById(id));
}
/**
* 通过id查询审计记录表
*/
@Operation(summary = "通过id查询", description = "通过id查询")
@GetMapping("/{id}")
@HasPermission("sys_audit_detail")
public R getById(@PathVariable("id") Long id) {
return R.ok(sysAuditLogService.getById(id));
}
/**
* 新增审计记录表 (异步插入)
* @param auditLogList 审计记录
* @return R
*/
@Inner
@PostMapping
@Operation(summary = "新增审计记录表", description = "新增审计记录表")
public R save(@RequestBody List<SysAuditLog> auditLogList) {
return R.ok(sysAuditLogService.saveBatch(auditLogList));
}
/**
* 新增审计记录表 (异步插入)
*
* @param auditLogList 审计记录
* @return R
*/
@Inner
@PostMapping
@Operation(summary = "新增审计记录表", description = "新增审计记录表")
public R save(@RequestBody List<SysAuditLog> auditLogList) {
return R.ok(sysAuditLogService.saveBatch(auditLogList));
}
/**
* 通过id删除审计记录表
* @param ids id列表
* @return R
*/
@Operation(summary = "通过id删除审计记录表", description = "通过id删除审计记录表")
@SysLog("通过id删除审计记录表")
@DeleteMapping("/delete")
@HasPermission("sys_audit_del")
public R removeById(@RequestBody Long[] ids) {
return R.ok(sysAuditLogService.removeBatchByIds(CollUtil.toList(ids)));
}
/**
* 通过id删除审计记录表
*
* @param ids id列表
* @return R
*/
@Operation(summary = "通过id删除审计记录表", description = "通过id删除审计记录表")
@SysLog("通过id删除审计记录表")
@DeleteMapping("/delete")
@HasPermission("sys_audit_del")
public R removeById(@RequestBody Long[] ids) {
return R.ok(sysAuditLogService.removeBatchByIds(CollUtil.toList(ids)));
}
/**
* 导出excel 表格
* @param sysAuditLog 查询条件
* @return excel 文件流
*/
@ResponseExcel
@GetMapping("/export")
@HasPermission("sys_audit_export")
public List<SysAuditLog> export(SysAuditLog sysAuditLog, Long[] ids) {
return sysAuditLogService
.list(Wrappers.lambdaQuery(sysAuditLog).in(ArrayUtil.isNotEmpty(ids), SysAuditLog::getId, ids));
}
/**
* 导出excel 表格
*
* @param sysAuditLog 查询条件
* @return excel 文件流
*/
@ResponseExcel
@GetMapping("/export")
@HasPermission("sys_audit_export")
public List<SysAuditLog> export(SysAuditLog sysAuditLog, Long[] ids) {
return sysAuditLogService
.list(Wrappers.lambdaQuery(sysAuditLog).in(ArrayUtil.isNotEmpty(ids), SysAuditLog::getId, ids));
}
}
@@ -62,120 +62,127 @@ import java.util.List;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SysClientController {
private final SysOauthClientDetailsService clientDetailsService;
private final SysOauthClientDetailsService clientDetailsService;
/**
* 通过ID查询
* @param clientId clientId
* @return SysOauthClientDetails
*/
@GetMapping("/{clientId}")
public R getByClientId(@PathVariable String clientId) {
SysOauthClientDetails details = clientDetailsService
.getOne(Wrappers.<SysOauthClientDetails>lambdaQuery().eq(SysOauthClientDetails::getClientId, clientId));
String information = details.getAdditionalInformation();
String captchaFlag = JSONUtil.parseObj(information).getStr(CommonConstants.CAPTCHA_FLAG);
String encFlag = JSONUtil.parseObj(information).getStr(CommonConstants.ENC_FLAG);
String onlineQuantity = JSONUtil.parseObj(information).getStr(CommonConstants.ONLINE_QUANTITY);
SysOauthClientDetailsDTO dto = new SysOauthClientDetailsDTO();
BeanUtils.copyProperties(details, dto);
dto.setCaptchaFlag(captchaFlag);
dto.setEncFlag(encFlag);
dto.setOnlineQuantity(onlineQuantity);
return R.ok(dto);
}
/**
* 通过ID查询
*
* @param clientId clientId
* @return SysOauthClientDetails
*/
@GetMapping("/{clientId}")
public R getByClientId(@PathVariable String clientId) {
SysOauthClientDetails details = clientDetailsService
.getOne(Wrappers.<SysOauthClientDetails>lambdaQuery().eq(SysOauthClientDetails::getClientId, clientId));
String information = details.getAdditionalInformation();
String captchaFlag = JSONUtil.parseObj(information).getStr(CommonConstants.CAPTCHA_FLAG);
String encFlag = JSONUtil.parseObj(information).getStr(CommonConstants.ENC_FLAG);
String onlineQuantity = JSONUtil.parseObj(information).getStr(CommonConstants.ONLINE_QUANTITY);
SysOauthClientDetailsDTO dto = new SysOauthClientDetailsDTO();
BeanUtils.copyProperties(details, dto);
dto.setCaptchaFlag(captchaFlag);
dto.setEncFlag(encFlag);
dto.setOnlineQuantity(onlineQuantity);
return R.ok(dto);
}
/**
* 简单分页查询
* @param page 分页对象
* @param sysOauthClientDetails 系统终端
* @return
*/
@GetMapping("/page")
public R getOauthClientDetailsPage(@ParameterObject Page page,
@ParameterObject SysOauthClientDetails sysOauthClientDetails) {
LambdaQueryWrapper<SysOauthClientDetails> wrapper = Wrappers.<SysOauthClientDetails>lambdaQuery()
.like(StrUtil.isNotBlank(sysOauthClientDetails.getClientId()), SysOauthClientDetails::getClientId,
sysOauthClientDetails.getClientId())
.like(StrUtil.isNotBlank(sysOauthClientDetails.getClientSecret()), SysOauthClientDetails::getClientSecret,
sysOauthClientDetails.getClientSecret());
return R.ok(clientDetailsService.page(page, wrapper));
}
/**
* 简单分页查询
*
* @param page 分页对象
* @param sysOauthClientDetails 系统终端
* @return
*/
@GetMapping("/page")
@HasPermission("sys_client_page")
public R getOauthClientDetailsPage(@ParameterObject Page page, @ParameterObject SysOauthClientDetails sysOauthClientDetails) {
LambdaQueryWrapper<SysOauthClientDetails> wrapper = Wrappers.<SysOauthClientDetails>lambdaQuery()
.like(StrUtil.isNotBlank(sysOauthClientDetails.getClientId()), SysOauthClientDetails::getClientId,
sysOauthClientDetails.getClientId())
.like(StrUtil.isNotBlank(sysOauthClientDetails.getClientSecret()), SysOauthClientDetails::getClientSecret,
sysOauthClientDetails.getClientSecret());
return R.ok(clientDetailsService.page(page, wrapper));
}
/**
* 添加
* @param clientDetailsDTO 实体
* @return success/false
*/
@SysLog("添加终端")
@PostMapping
@HasPermission("sys_client_add")
public R add(@Valid @RequestBody SysOauthClientDetailsDTO clientDetailsDTO) {
return R.ok(clientDetailsService.saveClient(clientDetailsDTO));
}
/**
* 添加
*
* @param clientDetailsDTO 实体
* @return success/false
*/
@SysLog("添加终端")
@PostMapping
@HasPermission("sys_client_add")
public R add(@Valid @RequestBody SysOauthClientDetailsDTO clientDetailsDTO) {
return R.ok(clientDetailsService.saveClient(clientDetailsDTO));
}
/**
* 删除
* @param ids ID 列表
* @return success/false
*/
@SysLog("删除终端")
@DeleteMapping
@HasPermission("sys_client_del")
public R removeById(@RequestBody Long[] ids) {
clientDetailsService.removeBatchByIds(CollUtil.toList(ids));
SpringContextHolder.publishEvent(new ClientDetailsInitRunner.ClientDetailsInitEvent(ids));
return R.ok();
}
/**
* 删除
*
* @param ids ID 列表
* @return success/false
*/
@SysLog("删除终端")
@DeleteMapping
@HasPermission("sys_client_del")
public R removeById(@RequestBody Long[] ids) {
clientDetailsService.removeBatchByIds(CollUtil.toList(ids));
SpringContextHolder.publishEvent(new ClientDetailsInitRunner.ClientDetailsInitEvent(ids));
return R.ok();
}
/**
* 编辑
* @param clientDetailsDTO 实体
* @return success/false
*/
@SysLog("编辑终端")
@PutMapping
@HasPermission("sys_client_edit")
public R update(@Valid @RequestBody SysOauthClientDetailsDTO clientDetailsDTO) {
return R.ok(clientDetailsService.updateClientById(clientDetailsDTO));
}
/**
* 编辑
*
* @param clientDetailsDTO 实体
* @return success/false
*/
@SysLog("编辑终端")
@PutMapping
@HasPermission("sys_client_edit")
public R update(@Valid @RequestBody SysOauthClientDetailsDTO clientDetailsDTO) {
return R.ok(clientDetailsService.updateClientById(clientDetailsDTO));
}
@Inner(false)
@GetMapping("/getClientDetailsById/{clientId}")
public R getClientDetailsById(@PathVariable String clientId) {
return R.ok(clientDetailsService.getOne(
Wrappers.<SysOauthClientDetails>lambdaQuery().eq(SysOauthClientDetails::getClientId, clientId), false));
}
@Inner(false)
@GetMapping("/getClientDetailsById/{clientId}")
public R getClientDetailsById(@PathVariable String clientId) {
return R.ok(clientDetailsService.getOne(
Wrappers.<SysOauthClientDetails>lambdaQuery().eq(SysOauthClientDetails::getClientId, clientId), false));
}
/**
* 查询全部客户端
* @return
*/
@Inner(false)
@GetMapping("/list")
public R listClients() {
return R.ok(clientDetailsService.list());
}
// /**
// * 查询全部客户端
// * @return
// */
// @Inner(false)
// @GetMapping("/list")
// public R listClients() {
// return R.ok(clientDetailsService.list());
// }
/**
* 同步缓存字典
* @return R
*/
@SysLog("同步终端")
@PutMapping("/sync")
public R sync() {
return clientDetailsService.syncClientCache();
}
/**
* 同步缓存字典
*
* @return R
*/
@SysLog("同步终端")
@PutMapping("/sync")
public R sync() {
return clientDetailsService.syncClientCache();
}
/**
* 导出所有客户端
* @return excel
*/
@ResponseExcel
@SysLog("导出excel")
@GetMapping("/export")
public List<SysOauthClientDetails> export(SysOauthClientDetails sysOauthClientDetails) {
return clientDetailsService.list(Wrappers.query(sysOauthClientDetails));
}
/**
* 导出所有客户端
*
* @return excel
*/
@ResponseExcel
@SysLog("导出excel")
@GetMapping("/export")
public List<SysOauthClientDetails> export(SysOauthClientDetails sysOauthClientDetails) {
return clientDetailsService.list(Wrappers.query(sysOauthClientDetails));
}
}
@@ -21,15 +21,17 @@ package com.bsd.pjcloud.admin.controller;
import cn.hutool.core.util.ObjectUtil;
import com.bsd.pjcloud.admin.api.dto.DeptAllListDto;
import com.bsd.pjcloud.admin.api.dto.DeptExportDTO;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.vo.DeptExcelVO;
import com.bsd.pjcloud.admin.mapper.SysDeptMapper;
import com.bsd.pjcloud.admin.service.SysDeptService;
import com.bsd.pjcloud.admin.service.impl.SysDeptBatchExportService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.excel.annotation.RequestExcel;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.annotation.HasPermission;
import com.bsd.pjcloud.common.security.annotation.Inner;
@@ -37,6 +39,7 @@ import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -68,11 +71,10 @@ public class SysDeptController {
private final SysDeptMapper sysDeptMapper;
private final SysDeptBatchExportService batchExportService;
/**
* 通过ID查询
*
* @param id ID
* @return SysDept
*/
@GetMapping("/{id}")
public R getById(@PathVariable Long id) {
@@ -80,11 +82,11 @@ public class SysDeptController {
}
/**
* 查询全部部门
* 查询数据权限范围内的部门
*/
@GetMapping("/list")
public R list() {
return R.ok(sysDeptService.list());
return R.ok(sysDeptMapper.selectListByScope(null, DataScope.of().only(true)));
}
/**
@@ -110,7 +112,6 @@ public class SysDeptController {
}
/**
* 添加
*
@@ -168,15 +169,18 @@ public class SysDeptController {
return R.ok(sysDeptService.listDeptLeader(deptId));
}
/**
* 导出部门
*
* @return
*/
@ResponseExcel
/** 按当前用户数据权限分批导出部门。 */
@GetMapping("/export")
public List<DeptExcelVO> export() {
return sysDeptService.listExcelVo();
@HasPermission("sys_dept_add")
public void export(HttpServletResponse response, DeptExportDTO query) throws Exception {
batchExportService.export(response, query);
}
/** 提交部门异步导出任务,沿用页面现有的导出操作权限。 */
@PostMapping("/export-async")
@HasPermission("sys_dept_add")
public R<AsyncTaskProgressVO> exportAsync(@RequestBody DeptExportDTO query) {
return R.ok(batchExportService.submit(query), "部门导出任务已提交");
}
/**
@@ -19,6 +19,7 @@
package com.bsd.pjcloud.admin.controller;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -93,6 +94,7 @@ public class SysDictController {
* @return 分页对象
*/
@GetMapping("/page")
@HasPermission("sys_dict_page")
public R<IPage> getDictPage(@ParameterObject Page page, @ParameterObject SysDict sysDict) {
return R.ok(sysDictService.page(page,
Wrappers.<SysDict>lambdaQuery()
@@ -164,6 +166,7 @@ public class SysDictController {
* @return
*/
@GetMapping("/list")
@HasPermission("sys_dict_page")
public R getDictList(String name) {
return R.ok(sysDictService.list(Wrappers.<SysDict>lambdaQuery()
.like(StrUtil.isNotBlank(name), SysDict::getDictType, name)
@@ -174,13 +177,10 @@ public class SysDictController {
}
/**
* 分页查询
*
* @param page 分页对象
* @param sysDictItem 字典项
* @return
* 字典项分页查询
*/
@GetMapping("/item/page")
@HasPermission("sys_dict_item_page")
public R getSysDictItemPage(Page page, SysDictItem sysDictItem) {
return R.ok(sysDictItemService.page(page, Wrappers.query(sysDictItem)));
}
@@ -257,6 +257,7 @@ public class SysDictController {
@ResponseExcel
@GetMapping("/export")
@HasPermission("sys_dict_export")
public List<SysDictItem> export(SysDictItem sysDictItem) {
return sysDictItemService.list(Wrappers.query(sysDictItem));
}
@@ -34,6 +34,10 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import com.bsd.pjcloud.admin.service.file.FileAccessGuard;
import com.bsd.pjcloud.common.core.file.FileAccessProperties;
import java.io.IOException;
import lombok.AllArgsConstructor;
import lombok.SneakyThrows;
import org.springdoc.core.annotations.ParameterObject;
@@ -59,6 +63,8 @@ import java.util.Objects;
public class SysFileController {
private final SysFileService sysFileService;
private final FileAccessGuard fileAccessGuard;
private final FileAccessProperties fileAccessProperties;
/* ************************************* start 远程调用 start ************************************* */
@Inner
@@ -140,8 +146,18 @@ public class SysFileController {
*/
@Inner(false)
@GetMapping("/oss/file")
public void file(String fileName, HttpServletResponse response) {
sysFileService.getFile(fileName, response);
public void file(String fileName, HttpServletRequest request, HttpServletResponse response) throws IOException {
if (fileAccessGuard.authorize(request, response)) {
sysFileService.getFile(fileName, response);
fileAccessGuard.recordReadResult(response.getStatus());
}
}
@Inner(false)
@GetMapping("/oss/access-info")
public R<Map<String, Long>> fileAccessInfo(HttpServletResponse response) {
response.setHeader("Cache-Control", "no-store");
return R.ok(Map.of("serverTime", System.currentTimeMillis(), "ttlSeconds", fileAccessProperties.getTtlSeconds()));
}
/**
@@ -20,18 +20,19 @@
package com.bsd.pjcloud.admin.controller;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.SysLogDTO;
import com.bsd.pjcloud.admin.api.entity.SysLog;
import com.bsd.pjcloud.admin.api.dto.SysLogExportDTO;
import com.bsd.pjcloud.admin.api.vo.PreLogVO;
import com.bsd.pjcloud.admin.service.SysLogService;
import com.bsd.pjcloud.admin.service.impl.SysLogBatchExportService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import com.bsd.pjcloud.common.security.annotation.HasPermission;
import com.bsd.pjcloud.common.security.annotation.Inner;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
@@ -39,7 +40,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Objects;
/**
* <p>
@@ -57,6 +57,7 @@ import java.util.Objects;
public class SysLogController {
private final SysLogService sysLogService;
private final SysLogBatchExportService batchExportService;
/**
* 简单分页查询
@@ -65,6 +66,7 @@ public class SysLogController {
* @return
*/
@GetMapping("/page")
@HasPermission("sys_log_page")
public R getLogPage(@ParameterObject Page page, @ParameterObject SysLogDTO sysLog) {
return R.ok(sysLogService.getLogByPage(page, sysLog));
}
@@ -74,6 +76,7 @@ public class SysLogController {
* @return R
*/
@GetMapping("/sum")
@HasPermission("sys_log_sum")
public R getLogSum() {
return R.ok(sysLogService.getLogSum());
}
@@ -113,13 +116,18 @@ public class SysLogController {
/**
* 导出excel 表格
* @param sysLog 查询条件
* @return
*/
@ResponseExcel
@GetMapping("/export")
@HasPermission("sys_log_export")
public List<SysLog> export(SysLog sysLog, Long[] ids) {
return sysLogService.list(Wrappers.lambdaQuery(sysLog).in(Objects.nonNull(ids), SysLog::getId, ids));
public void export(HttpServletResponse response, @ParameterObject SysLogExportDTO sysLog) throws Exception {
batchExportService.export(response, sysLog);
}
/** 提交日志分批导出任务,复用用户导出的进度和文件下载机制。 */
@PostMapping("/export-async")
@HasPermission("sys_log_export")
public R<AsyncTaskProgressVO> exportAsync(@RequestBody SysLogExportDTO sysLog) {
return R.ok(batchExportService.submit(sysLog), "日志导出任务已提交");
}
}
@@ -367,9 +367,6 @@ public class SysUserController {
/**
* 导出excel 表格
*
* @param userDTO 查询条件
* @return
*/
@PostMapping("/export")
@HasPermission("sys_user_export")
@@ -386,10 +383,6 @@ public class SysUserController {
/**
* 导入用户
*
* @param excelVOList 用户列表
* @param bindingResult 错误信息列表
* @return R
*/
@PostMapping("/import")
@HasPermission("sys_user_export")
@@ -19,8 +19,12 @@
package com.bsd.pjcloud.admin.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.DeptAllListDto;
import com.bsd.pjcloud.admin.api.dto.DeptExportDTO;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.vo.DeptExcelVO;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -39,5 +43,9 @@ import java.util.List;
public interface SysDeptMapper extends PjcloudBaseMapper<SysDept> {
List<SysDept> allList(@Param("dto") DeptAllListDto dto);
/** 导出按部门数据权限分页,同时关联上级部门名称。 */
Page<DeptExcelVO> selectExportPageByScope(Page<DeptExcelVO> page, @Param("query") DeptExportDTO query,
DataScope dataScope);
}
@@ -72,8 +72,6 @@ public interface SysDeptService extends IService<SysDept> {
*/
Boolean create(@Valid SysDept sysDept);
List<DeptExcelVO> listExcelVo();
R importDept(List<DeptExcelVO> excelVOList, BindingResult bindingResult);
/**
@@ -0,0 +1,125 @@
package com.bsd.pjcloud.admin.service.file;
import com.bsd.pjcloud.common.core.file.FileAccessProperties;
import com.bsd.pjcloud.common.core.file.FileAccessSignature;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.Clock;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
@Slf4j
@Component
public class FileAccessGuard {
private static final Set<String> APPS = Set.of("admin", "h5", "mini", "flutter", "service", "package");
private static final List<String> FIELDS = List.of("fileName", "appId", "kid", "ts", "sign");
private static final Pattern DART = Pattern.compile("^Dart/\\d+\\.\\d+(?:\\.\\d+)? \\(dart:io\\)$");
private final FileAccessProperties properties;
private final MeterRegistry meters;
private final Clock clock;
private final Map<String, AtomicLong> lastLogs = new ConcurrentHashMap<>();
@org.springframework.beans.factory.annotation.Autowired
public FileAccessGuard(FileAccessProperties properties, ObjectProvider<MeterRegistry> meters) {
this(properties, meters.getIfAvailable(), Clock.systemUTC());
}
public FileAccessGuard(FileAccessProperties properties, MeterRegistry meters, Clock clock) {
this.properties = properties;
this.meters = meters;
this.clock = clock;
}
public record Decision(int status, String reason, long cacheSeconds) { }
public Decision evaluate(HttpServletRequest request) {
properties.validate();
for (String field : FIELDS) {
String[] values = request.getParameterValues(field);
if (values != null && values.length != 1) return new Decision(400, "duplicate_parameter", 0);
}
String file = request.getParameter("fileName");
if (file == null || file.isBlank() || file.length() > 1024 || file.contains("\r") || file.contains("\n")) {
return new Decision(400, "invalid_file_name", 0);
}
if (properties.isBlockBrowserNavigation() && browserNavigation(request)) {
return new Decision(403, "browser_navigation", 0);
}
boolean signed = FIELDS.stream().filter(f -> !f.equals("fileName")).anyMatch(f -> request.getParameterValues(f) != null);
if (!signed) {
String ua = Objects.toString(request.getHeader("User-Agent"), "");
int dot = file.lastIndexOf('.');
String extension = dot < 0 ? "" : file.substring(dot + 1).toLowerCase(Locale.ROOT);
if (properties.getLegacyDart().isEnabled() && DART.matcher(ua).matches()
&& properties.getLegacyDart().getExtensions().contains(extension)) {
return new Decision(200, "legacy_dart", 300);
}
return new Decision(403, "missing_signature", 0);
}
String app = request.getParameter("appId"), kid = request.getParameter("kid"), ts = request.getParameter("ts"), sign = request.getParameter("sign");
if (app == null || kid == null || ts == null || sign == null || !kid.matches("[a-zA-Z0-9_-]{1,32}")
|| !ts.matches("(?:0|[1-9][0-9]{0,11})") || !sign.matches("[0-9a-f]{64}")) {
return new Decision(400, "malformed_signature", 0);
}
if (!APPS.contains(app)) return new Decision(403, "unknown_client", 0);
String key = properties.key(app, kid);
if (key == null || key.isBlank()) return new Decision(403, "unknown_key", 0);
long issuedAt = Long.parseLong(ts), now = clock.instant().getEpochSecond();
if (issuedAt > now + properties.getFutureSkewSeconds()) return new Decision(403, "clock_skew", 0);
if (now - issuedAt >= properties.ttl(app)) return new Decision(403, "expired", 0);
if (!FileAccessSignature.matches(FileAccessSignature.sign(file, app, kid, issuedAt, key), sign)) {
return new Decision(403, "invalid_signature", 0);
}
return new Decision(200, "signed", Math.max(0, Math.min(300, issuedAt + properties.ttl(app) - now)));
}
private boolean browserNavigation(HttpServletRequest request) {
return Collections.list(request.getHeaders("Sec-Fetch-Mode")).stream().anyMatch(value -> "navigate".equalsIgnoreCase(value.trim()))
|| Collections.list(request.getHeaders("Sec-Fetch-Dest")).stream()
.anyMatch(value -> Set.of("document", "iframe", "frame").contains(value.trim().toLowerCase(Locale.ROOT)));
}
public void recordReadResult(int status) {
if (status >= 400 && meters != null) meters.counter("file.access.read.failures", "status", status == 404 ? "404" : "500").increment();
}
public boolean authorize(HttpServletRequest request, HttpServletResponse response) throws IOException {
// A cached <img> response must not be reused for a document navigation of the same URL.
response.setHeader("Vary", "User-Agent, Sec-Fetch-Mode, Sec-Fetch-Dest");
Decision decision;
try { decision = evaluate(request); }
catch (RuntimeException e) { decision = new Decision(500, "configuration_error", 0); }
String app = request.getParameter("appId");
String safeApp = APPS.contains(Objects.toString(app, "")) ? app : "unknown";
if (meters != null) meters.counter("file.access.requests", "outcome", decision.reason(), "app", safeApp,
"mode", properties.getMode()).increment();
if (!"signed".equals(decision.reason())) {
long now = clock.millis();
AtomicLong last = lastLogs.computeIfAbsent(decision.reason(), ignored -> new AtomicLong());
long previous = last.get();
if (now - previous >= 60_000 && last.compareAndSet(previous, now)) {
log.info("file-access mode={} outcome={} app={} remote={}", properties.getMode(), decision.reason(), safeApp, request.getRemoteAddr());
}
}
// Invalid file names and configuration failures are errors even during observation.
if (decision.status() != 200 && (properties.enforcing() || decision.status() == 500
|| decision.reason().equals("invalid_file_name") || decision.reason().equals("duplicate_parameter"))) {
response.setStatus(decision.status());
response.setHeader("Cache-Control", "no-store");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":" + decision.status() + ",\"msg\":\"文件访问校验失败\",\"reason\":\"" + decision.reason() + "\"}");
return false;
}
long age = decision.status() == 200 ? decision.cacheSeconds() : 0;
response.setHeader("Cache-Control", "private, max-age=" + age);
return true;
}
}
@@ -0,0 +1,106 @@
package com.bsd.pjcloud.admin.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.DeptExportDTO;
import com.bsd.pjcloud.admin.api.vo.DeptExcelVO;
import com.bsd.pjcloud.admin.mapper.SysDeptMapper;
import com.bsd.pjcloud.admin.service.SysFileService;
import com.bsd.pjcloud.common.asynctask.service.*;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.excel.config.ExcelConfigProperties;
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import com.bsd.pjcloud.common.excel.stream.ExportMultipartFile;
import com.bsd.pjcloud.common.excel.style.ExcelExportStyleHandler;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
/** 同步及异步部门导出共用权限分页与 Excel 样式。 */
@Service
@RequiredArgsConstructor
public class SysDeptBatchExportService {
private static final String FILE_NAME = "部门管理.xlsx";
private static final Map<String, Double> COLUMN_WIDTHS = Map.of(
"parentName", 32D, "name", 32D, "sortOrder", 14D);
private final SysDeptMapper mapper;
private final SysFileService files;
private final AsyncTaskExecutorService tasks;
private final ExcelConfigProperties excelConfig;
public AsyncTaskProgressVO submit(DeptExportDTO dto) {
DeptExportDTO query = BeanUtil.toBean(dto, DeptExportDTO.class);
ExcelExportStyleHandler style = new ExcelExportStyleHandler(excelConfig.getExportStyle(), COLUMN_WIDTHS);
return tasks.submitWithResult("admin:export:dept", "部门导出", 1,
reporter -> generate(query, reporter, style, this::upload));
}
/** 保留原 GET 导出的文件响应,生成完成后才写出响应字节。 */
public void export(HttpServletResponse response, DeptExportDTO query) throws Exception {
ExcelExportStyleHandler style = new ExcelExportStyleHandler(excelConfig.getExportStyle(), COLUMN_WIDTHS);
generate(query, null, style, file -> BatchExportSupport.respond(file, FILE_NAME, response));
}
private <T> T generate(DeptExportDTO query, AsyncTaskProgressReporter reporter, ExcelExportStyleHandler style,
BatchExcelExportTemplate.FileConsumer<T> consumer) throws Exception {
Page<DeptExcelVO> first = fetch(query, 1);
long total = first.getTotal();
if (total == 0) throw new AsyncTaskException("当前筛选条件没有可导出的部门");
int maximum = BatchExcelExportTemplate.calculateMaxDetailRowCount(1, 0);
if (total > maximum) throw new AsyncTaskException("导出数据超过单工作表支持的 " + maximum + " 条,请缩小筛选范围");
if (reporter != null) {
reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(total, BatchExportSupport.BATCH_SIZE, 2),
"共 " + total + " 个部门,正在生成 Excel");
}
Path output = Files.createTempFile("dept-export-", ".xlsx");
try {
long written = 0;
try (ExcelWriter writer = EasyExcel.write(output.toFile()).head(DeptExcelVO.class)
.registerWriteHandler(style).build()) {
WriteSheet sheet = EasyExcel.writerSheet("部门管理").build();
for (long current = 1; ; current++) {
List<DeptExcelVO> rows = current == 1 ? first.getRecords() : fetch(query, current).getRecords();
if (rows.isEmpty()) break;
written += rows.size();
if (written > total) throw new AsyncTaskException("导出期间部门数据发生变化,请重试");
writer.write(rows, sheet);
if (reporter != null) reporter.advance("已写入 " + written + " / " + total + " 个部门");
}
if (written != total) throw new AsyncTaskException("导出期间部门数据发生变化,请重试");
}
if (reporter != null) reporter.advance("文件已生成,正在保存导出结果");
return consumer.consume(output);
} finally {
Files.deleteIfExists(output);
}
}
private Page<DeptExcelVO> fetch(DeptExportDTO query, long current) {
Page<DeptExcelVO> page = BatchExportSupport.page(current);
// 分页插件在 DataScope 外层追加排序,确保受限用户的各批次也保持唯一顺序。
page.addOrder(OrderItem.asc("sort_order"), OrderItem.asc("dept_id"));
return mapper.selectExportPageByScope(page, query, DataScope.of());
}
private AsyncTaskResult upload(Path file) {
String encoded = new String(FILE_NAME.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
R<?> result = files.uploadFile(new ExportMultipartFile(file, encoded), "export", null, "excel");
if (result == null || !result.isOk() || !(result.getData() instanceof Map<?, ?> data) || data.get("url") == null) {
throw new AsyncTaskException("导出文件保存失败,请重试");
}
return AsyncTaskResult.file(data.get("url").toString(), FILE_NAME);
}
}
@@ -261,29 +261,6 @@ public class SysDeptServiceImpl extends ServiceImpl<SysDeptMapper, SysDept> impl
}
/**
* 导出部门
*
* @return
*/
@Override
public List<DeptExcelVO> listExcelVo() {
List<SysDept> list = this.list();
List<DeptExcelVO> deptExcelVos = list.stream().map(item -> {
DeptExcelVO deptExcelVo = new DeptExcelVO();
deptExcelVo.setName(item.getName());
Optional<String> first = this.list()
.stream()
.filter(it -> item.getParentId().equals(it.getDeptId()))
.map(SysDept::getName)
.findFirst();
deptExcelVo.setParentName(first.orElse("根部门"));
deptExcelVo.setSortOrder(item.getSortOrder());
return deptExcelVo;
}).collect(Collectors.toList());
return deptExcelVos;
}
@Override
public R importDept(List<DeptExcelVO> excelVOList, BindingResult bindingResult) {
List<ErrorMessage> errorMessageList = (List<ErrorMessage>) bindingResult.getTarget();
@@ -149,19 +149,47 @@ public class SysFileServiceImpl extends ServiceImpl<SysFileMapper, SysFile> impl
*/
@Override
public void getFile(String fileName, HttpServletResponse response) {
TenantContextHolder.setTenantSkip();
SysFile sysFile = baseMapper.selectOne(Wrappers.<SysFile>lambdaQuery().eq(SysFile::getFileName, fileName));
SysFile sysFile;
boolean previousSkip = TenantContextHolder.getTenantSkip();
try {
TenantContextHolder.setTenantSkip();
sysFile = baseMapper.selectOne(Wrappers.<SysFile>lambdaQuery().eq(SysFile::getFileName, fileName));
} catch (Exception e) {
fileError(response, 500);
log.warn("文件信息查询失败 type={}", e.getClass().getSimpleName());
return;
} finally {
TenantContextHolder.setTenantSkip(previousSkip);
}
if (sysFile == null) {
fileError(response, 404);
return;
}
try (S3Object s3Object = fileTemplate.getObject(sysFile.getBucketName(), sysFile.getDir(), fileName)) {
response.setContentType("application/octet-stream; charset=UTF-8");
// 文件唯一hash
response.addHeader("Hash", sysFile.getHash());
response.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + URLUtil.encode(sysFile.getOriginal()));
IoUtil.copy(s3Object.getObjectContent(), response.getOutputStream());
} catch (com.amazonaws.services.s3.model.AmazonS3Exception e) {
fileError(response, e.getStatusCode() == 404 ? 404 : 500);
log.warn("文件存储读取失败 status={}", e.getStatusCode());
} catch (Exception e) {
log.error("文件读取异常: {}", e.getLocalizedMessage());
fileError(response, 500);
log.warn("文件读取失败 type={}", e.getClass().getSimpleName());
}
}
private void fileError(HttpServletResponse response, int status) {
if (response.isCommitted()) return;
response.reset();
response.setStatus(status);
response.setHeader("Cache-Control", "no-store");
response.setContentType("application/json;charset=UTF-8");
try {
response.getWriter().write("{\"code\":" + status + ",\"msg\":\"文件不存在或读取失败\"}");
} catch (java.io.IOException ignored) { }
}
/**
* 删除文件
*
@@ -0,0 +1,155 @@
package com.bsd.pjcloud.admin.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.SysLogExportDTO;
import com.bsd.pjcloud.admin.api.entity.SysLog;
import com.bsd.pjcloud.admin.mapper.SysLogMapper;
import com.bsd.pjcloud.admin.service.SysFileService;
import com.bsd.pjcloud.common.asynctask.service.*;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.excel.config.ExcelConfigProperties;
import com.bsd.pjcloud.common.excel.converters.LocalDateTimeStringConverter;
import com.bsd.pjcloud.common.excel.converters.LongStringConverter;
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import com.bsd.pjcloud.common.excel.stream.ExportMultipartFile;
import com.bsd.pjcloud.common.excel.style.ExcelExportStyleHandler;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** 日志按主键游标分批读取,同步和异步导出共用原有列及统一 Excel 样式。 */
@Service
@RequiredArgsConstructor
public class SysLogBatchExportService {
private static final String FILE_NAME = "操作日志.xlsx";
private static final Map<String, Double> COLUMN_WIDTHS = Map.ofEntries(
Map.entry("id", 24D), Map.entry("logType", 28D), Map.entry("title", 32D),
Map.entry("createBy", 20D), Map.entry("createTime", 24D), Map.entry("remoteAddr", 20D),
Map.entry("userAgent", 40D), Map.entry("requestUri", 40D), Map.entry("method", 16D),
Map.entry("params", 50D), Map.entry("time", 16D), Map.entry("exception", 60D),
Map.entry("serviceId", 24D));
private final SysLogMapper mapper;
private final SysFileService files;
private final AsyncTaskExecutorService tasks;
private final ExcelConfigProperties excelConfig;
public AsyncTaskProgressVO submit(SysLogExportDTO dto) {
SysLogExportDTO query = snapshot(dto);
ExcelExportStyleHandler style = new ExcelExportStyleHandler(excelConfig.getExportStyle(), COLUMN_WIDTHS);
return tasks.submitWithResult("admin:export:log", "日志导出", 1,
reporter -> generate(query, reporter, style, this::upload));
}
/** 文件完整生成后才写出响应,失败时仍可返回正常的错误提示。 */
public void export(HttpServletResponse response, SysLogExportDTO dto) throws Exception {
SysLogExportDTO query = snapshot(dto);
ExcelExportStyleHandler style = new ExcelExportStyleHandler(excelConfig.getExportStyle(), COLUMN_WIDTHS);
generate(query, null, style, file -> BatchExportSupport.respond(file, FILE_NAME, response));
}
private SysLogExportDTO snapshot(SysLogExportDTO dto) {
SysLogExportDTO query = BeanUtil.toBean(dto, SysLogExportDTO.class);
LocalDateTime[] range = dto.getCreateTime();
if (range != null && range.length > 0 && Arrays.stream(range).anyMatch(Objects::nonNull)) {
if (range.length != 2 || range[0] == null || range[1] == null || range[0].isAfter(range[1])) {
throw new AsyncTaskException("请选择有效的日志创建时间范围");
}
query.setCreateTime(range.clone());
} else {
query.setCreateTime(null);
}
query.setIds(dto.getIds() == null ? null : dto.getIds().clone());
return query;
}
private <T> T generate(SysLogExportDTO query, AsyncTaskProgressReporter reporter, ExcelExportStyleHandler style,
BatchExcelExportTemplate.FileConsumer<T> consumer) throws Exception {
Page<SysLog> first = fetch(query, null);
long total = first.getTotal();
if (total == 0) throw new AsyncTaskException("当前筛选条件没有可导出的日志");
int maximum = BatchExcelExportTemplate.calculateMaxDetailRowCount(1, 0);
if (total > maximum) throw new AsyncTaskException("导出数据超过单工作表支持的 " + maximum + " 条,请缩小筛选范围");
if (reporter != null) {
reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(total, BatchExportSupport.BATCH_SIZE, 2),
"共 " + total + " 条日志,正在生成 Excel");
}
Path output = Files.createTempFile("log-export-", ".xlsx");
try {
long written = 0;
try (ExcelWriter writer = EasyExcel.write(output.toFile()).head(SysLog.class)
.registerConverter(LocalDateTimeStringConverter.INSTANCE)
.registerConverter(LongStringConverter.INSTANCE)
.registerWriteHandler(style).build()) {
WriteSheet sheet = EasyExcel.writerSheet("操作日志").build();
List<SysLog> rows = first.getRecords();
while (!rows.isEmpty()) {
written += rows.size();
if (written > total) throw new AsyncTaskException("导出期间日志数据发生变化,请重试");
writer.write(rows, sheet);
if (reporter != null) reporter.advance("已写入 " + written + " / " + total + " 条日志");
rows = fetch(query, rows.get(rows.size() - 1).getId()).getRecords();
}
if (written != total) throw new AsyncTaskException("导出期间日志数据发生变化,请重试");
}
if (reporter != null) reporter.advance("文件已生成,正在保存导出结果");
return consumer.consume(output);
} finally {
Files.deleteIfExists(output);
}
}
private Page<SysLog> fetch(SysLogExportDTO query, Long lastId) {
// 各批次都只取游标后的前 200 条;新增的更大编号不会造成重复或页码偏移。
Page<SysLog> page = BatchExportSupport.page(1);
page.setSearchCount(lastId == null);
LambdaQueryWrapper<SysLog> wrapper = Wrappers.<SysLog>lambdaQuery()
.eq(query.getId() != null, SysLog::getId, query.getId())
.eq(StrUtil.isNotBlank(query.getLogType()), SysLog::getLogType, query.getLogType())
.eq(StrUtil.isNotBlank(query.getTitle()), SysLog::getTitle, query.getTitle())
.eq(StrUtil.isNotBlank(query.getCreateBy()), SysLog::getCreateBy, query.getCreateBy())
.eq(query.getUpdateTime() != null, SysLog::getUpdateTime, query.getUpdateTime())
.eq(StrUtil.isNotBlank(query.getRemoteAddr()), SysLog::getRemoteAddr, query.getRemoteAddr())
.eq(StrUtil.isNotBlank(query.getUserAgent()), SysLog::getUserAgent, query.getUserAgent())
.eq(StrUtil.isNotBlank(query.getRequestUri()), SysLog::getRequestUri, query.getRequestUri())
.eq(StrUtil.isNotBlank(query.getMethod()), SysLog::getMethod, query.getMethod())
.eq(StrUtil.isNotBlank(query.getParams()), SysLog::getParams, query.getParams())
.eq(query.getTime() != null, SysLog::getTime, query.getTime())
.eq(StrUtil.isNotBlank(query.getException()), SysLog::getException, query.getException())
.eq(StrUtil.isNotBlank(query.getServiceId()), SysLog::getServiceId, query.getServiceId())
.in(query.getIds() != null && query.getIds().length > 0, SysLog::getId, (Object[]) query.getIds())
.lt(lastId != null, SysLog::getId, lastId)
.orderByDesc(SysLog::getId);
if (query.getCreateTime() != null) {
wrapper.ge(SysLog::getCreateTime, query.getCreateTime()[0])
.le(SysLog::getCreateTime, query.getCreateTime()[1]);
}
return mapper.selectPage(page, wrapper);
}
private AsyncTaskResult upload(Path file) {
String encoded = new String(FILE_NAME.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
R<?> result = files.uploadFile(new ExportMultipartFile(file, encoded), "export", null, "excel");
if (result == null || !result.isOk() || !(result.getData() instanceof Map<?, ?> data) || data.get("url") == null) {
throw new AsyncTaskException("导出文件保存失败,请重试");
}
return AsyncTaskResult.file(data.get("url").toString(), FILE_NAME);
}
}
@@ -92,4 +92,18 @@
<include refid="whereA"/>
</where>
</select>
<!-- dept_id、create_by 供 DataScope 在分页及计数前过滤导出部门。 -->
<select id="selectExportPageByScope" resultType="com.bsd.pjcloud.admin.api.vo.DeptExcelVO">
SELECT d.dept_id, d.create_by, d.name, d.sort_order,
COALESCE(p.name, '根部门') AS parent_name
FROM sys_dept d
LEFT JOIN sys_dept p ON p.dept_id = d.parent_id AND p.tenant_id = d.tenant_id AND p.del_flag = '0'
<where>
d.del_flag = '0'
<if test="query.deptName != null and query.deptName != ''">
<bind name="deptNameLike" value="'%'+query.deptName+'%'"/>
AND d.name LIKE #{deptNameLike}
</if>
</where>
</select>
</mapper>
@@ -319,7 +319,7 @@
</if>
</select>
<!-- 导出专用分页,保留原导出 SQL 的筛选与关联。 -->
<!-- 导出专用分页,支持审核状态筛选并保留原有筛选与关联。 -->
<select id="selectVoExportPageByScope" resultMap="baseResultMap">
SELECT
u.user_id,
@@ -354,6 +354,9 @@
LEFT JOIN sys_user_role urole ON urole.user_id = u.user_id
<where>
u.del_flag = '0'
<if test="query.processStatus != null and query.processStatus != ''">
AND u.process_status = #{query.processStatus}
</if>
<if test="query.username != null and query.username != ''">
<bind name="usernameLike" value="'%'+query.username+'%'"/>
AND u.username LIKE #{usernameLike}
@@ -0,0 +1,210 @@
package com.bsd.pjcloud.admin.service.file;
import com.bsd.pjcloud.admin.api.config.FileAccessFeignConfiguration;
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
import com.bsd.pjcloud.admin.controller.SysFileController;
import com.bsd.pjcloud.admin.service.SysFileService;
import com.bsd.pjcloud.common.core.file.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.Feign;
import feign.Response;
import org.springframework.cloud.openfeign.support.SpringMvcContract;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class FileAccessTest {
static final long NOW = 1793232000L;
static final String KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
final Clock clock = Clock.fixed(Instant.ofEpochSecond(NOW), ZoneOffset.UTC);
FileAccessProperties properties(String mode) {
var p = new FileAccessProperties();
p.setMode(mode); p.setPublicBaseUrl("https://files.example/api");
for (String app : List.of("admin", "h5", "mini", "flutter", "service", "package")) {
var client = new FileAccessProperties.Client(); client.getKeys().put("v1", KEY); p.getClients().put(app, client);
}
return p;
}
MockHttpServletRequest signed(String file, String app, long ts) {
var r = new MockHttpServletRequest("GET", "/sys-file/oss/file");
r.setParameter("fileName", file); r.setParameter("appId", app); r.setParameter("kid", "v1"); r.setParameter("ts", "" + ts);
r.setParameter("sign", FileAccessSignature.sign(file, app, "v1", ts, KEY)); return r;
}
@Test void sharedGoldenVectors() throws Exception {
JsonNode vectors = new ObjectMapper().readTree(getClass().getResourceAsStream("/file-access-vectors.json"));
for (JsonNode v : vectors) {
assertEquals(v.get("canonical").asText(), FileAccessSignature.canonical(v.get("fileName").asText(), v.get("appId").asText(), v.get("kid").asText(), v.get("ts").asLong()));
assertEquals(v.get("sign").asText(), FileAccessSignature.sign(v.get("fileName").asText(), v.get("appId").asText(), v.get("kid").asText(), v.get("ts").asLong(), v.get("key").asText()));
}
}
@Test void validSignaturesCannotNavigateButImagesFetchAndNativeClientsStillWork() throws Exception {
var p = properties("enforce"); var guard = new FileAccessGuard(p, null, clock);
for (String app : List.of("admin", "h5", "mini", "flutter", "service", "package")) {
var request = signed("a.jpg", app, NOW);
request.addHeader("Sec-Fetch-Mode", "navigate"); request.addHeader("Sec-Fetch-Dest", "document");
assertEquals("browser_navigation", guard.evaluate(request).reason());
var response = new MockHttpServletResponse();
assertFalse(guard.authorize(request, response));
assertEquals(403, response.getStatus()); assertEquals("no-store", response.getHeader("Cache-Control"));
}
for (String destination : List.of("image", "video", "audio", "empty")) {
var request = signed("a.jpg", "admin", NOW);
request.addHeader("Sec-Fetch-Mode", destination.equals("empty") ? "cors" : "no-cors");
request.addHeader("Sec-Fetch-Dest", destination);
var response = new MockHttpServletResponse();
assertTrue(guard.authorize(request, response));
assertEquals("User-Agent, Sec-Fetch-Mode, Sec-Fetch-Dest", response.getHeader("Vary"));
}
assertEquals(200, guard.evaluate(signed("a.apk", "package", NOW)).status());
assertEquals(200, guard.evaluate(signed("a.jpg", "service", NOW)).status());
for (String destination : List.of("document", "iframe", "frame")) {
var request = signed("a.jpg", "admin", NOW); request.addHeader("Sec-Fetch-Dest", destination);
assertEquals(403, guard.evaluate(request).status());
}
var dart = new MockHttpServletRequest(); dart.setParameter("fileName", "a.jpg");
dart.addHeader("User-Agent", "Dart/3.6 (dart:io)"); dart.addHeader("Sec-Fetch-Mode", "navigate");
assertEquals(403, guard.evaluate(dart).status());
var request = signed("a.jpg", "admin", NOW); request.addHeader("Sec-Fetch-Mode", "navigate");
p.setMode("observe"); assertTrue(guard.authorize(request, new MockHttpServletResponse()));
p.setMode("enforce"); p.setBlockBrowserNavigation(false);
assertEquals(200, guard.evaluate(request).status());
}
@Test void signatureAndTimeBoundaries() {
var guard = new FileAccessGuard(properties("enforce"), null, clock);
assertEquals(200, guard.evaluate(signed("a.jpg", "mini", NOW)).status());
assertEquals(403, guard.evaluate(signed("a.jpg", "mini", NOW - 1800)).status());
assertEquals(200, guard.evaluate(signed("a.jpg", "mini", NOW + 300)).status());
assertEquals(403, guard.evaluate(signed("a.jpg", "mini", NOW + 301)).status());
assertEquals(200, guard.evaluate(signed("a.apk", "package", NOW - 3600)).status());
assertEquals(403, guard.evaluate(signed("a.apk", "package", NOW - 86400)).status());
var changed = signed("a.jpg", "mini", NOW); changed.setParameter("fileName", "b.jpg");
assertEquals("invalid_signature", guard.evaluate(changed).reason());
changed.setParameter("fileName", "a.jpg", "b.jpg"); assertEquals(400, guard.evaluate(changed).status());
var unknown = signed("a.jpg", "mini", NOW); unknown.setParameter("kid", "missing"); assertEquals(403, guard.evaluate(unknown).status());
}
@Test void dartCompatibilityIsImageOnlyAndNeverMasksBadSignatures() {
var p = properties("enforce"); var guard = new FileAccessGuard(p, null, clock);
var request = new MockHttpServletRequest(); request.setParameter("fileName", "a.jpg");
request.addHeader("User-Agent", "Dart/3.6 (dart:io)");
assertEquals("legacy_dart", guard.evaluate(request).reason());
request.setParameter("fileName", "a.apk"); assertEquals(403, guard.evaluate(request).status());
request.setParameter("fileName", "a.pdf"); assertEquals(403, guard.evaluate(request).status());
request.setParameter("fileName", "a.jpg"); request.setParameter("sign", "bad"); assertEquals(400, guard.evaluate(request).status());
request.removeParameter("sign"); p.getLegacyDart().setEnabled(false); assertEquals(403, guard.evaluate(request).status());
}
@Test void observationRecordsButDoesNotBlockAndRefreshFailsClosed() throws Exception {
var p = properties("observe"); var meters = new SimpleMeterRegistry(); var guard = new FileAccessGuard(p, meters, clock);
var request = new MockHttpServletRequest(); request.setParameter("fileName", "a.jpg");
var response = new MockHttpServletResponse(); assertTrue(guard.authorize(request, response));
assertEquals(1.0, meters.find("file.access.requests").tag("outcome", "missing_signature").counter().count());
p.setMode("enforce"); response = new MockHttpServletResponse(); assertFalse(guard.authorize(request, response)); assertEquals(403, response.getStatus());
assertEquals("no-store", response.getHeader("Cache-Control"));
p.getClients().clear(); response = new MockHttpServletResponse(); assertFalse(guard.authorize(request, response)); assertEquals(500, response.getStatus());
}
@Test void controllerGatesGetAndHeadBeforeStorageAndSupportsAnonymousClock() throws Exception {
var p = properties("enforce"); var service = mock(SysFileService.class); var guard = new FileAccessGuard(p, null, clock);
var mvc = MockMvcBuilders.standaloneSetup(new SysFileController(service, guard, p)).build();
mvc.perform(get("/sys-file/oss/file").param("fileName", "a.jpg").header("Authorization", "Bearer arbitrary")).andExpect(status().isForbidden());
mvc.perform(head("/sys-file/oss/file").param("fileName", "a.jpg")).andExpect(status().isForbidden());
verifyZeroInteractions(service);
for (var builder : List.of(get("/sys-file/oss/file"), head("/sys-file/oss/file"))) {
mvc.perform(builder.param("fileName", "a.jpg").param("appId", "admin").param("kid", "v1").param("ts", "" + NOW)
.param("sign", FileAccessSignature.sign("a.jpg", "admin", "v1", NOW, KEY))
.header("Sec-Fetch-Mode", "navigate").header("Sec-Fetch-Dest", "document"))
.andExpect(status().isForbidden()).andExpect(header().string("Cache-Control", "no-store"));
}
verifyZeroInteractions(service);
mvc.perform(get("/sys-file/oss/file").param("fileName", "a.jpg").param("appId", "mini").param("kid", "v1").param("ts", "" + NOW)
.param("sign", FileAccessSignature.sign("a.jpg", "mini", "v1", NOW, KEY))).andExpect(status().isOk()).andExpect(header().string("Cache-Control", "private, max-age=300"));
verify(service).getFile(eq("a.jpg"), any());
mvc.perform(head("/sys-file/oss/file").param("fileName", "a.jpg").param("appId", "mini").param("kid", "v1").param("ts", "" + NOW)
.param("sign", FileAccessSignature.sign("a.jpg", "mini", "v1", NOW, KEY))).andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "private, max-age=300"));
verify(service, times(2)).getFile(eq("a.jpg"), any());
mvc.perform(get("/sys-file/oss/access-info")).andExpect(status().isOk()).andExpect(jsonPath("$.data.ttlSeconds").value(1800))
.andExpect(jsonPath("$.data.serverTime").isNumber()).andExpect(jsonPath("$.data.keys").doesNotExist());
}
@Test void packageUrlAndFeignEncodeExactlyOnceAndDoNotSignOtherHosts() throws Exception {
var signer = new FileAccessSigner(properties("observe"), clock);
String apk = signer.signUrl("installer.apk", "package"); assertTrue(apk.endsWith("fileName=installer.apk"));
assertTrue(apk.startsWith("https://files.example/api/admin/sys-file/oss/file?"));
String external = "https://other.example/admin/sys-file/oss/file?fileName=a.jpg";
assertEquals(external, signer.signUrl(external, "package"));
assertEquals("https://files.example/static/package.apk", signer.signUrl("https://files.example/static/package.apk", "package"));
var interceptor = new FileAccessFeignConfiguration().fileAccessRequestInterceptor(signer);
var captured = new java.util.concurrent.atomic.AtomicReference<feign.Request>();
RemoteFileService client = Feign.builder().contract(new SpringMvcContract())
.requestInterceptor(template -> { interceptor.apply(template); interceptor.apply(template); })
.client((request, options) -> {
captured.set(request);
return Response.builder().request(request).status(200).reason("OK").headers(Map.of()).body(new byte[0]).build();
}).target(RemoteFileService.class, "https://files.example");
for (String name : List.of("照片 +&%.png", "literal%2F%2B.jpg", "installer.apk")) {
try (Response ignored = client.getFile(name)) {
String query = URI.create(captured.get().url()).getRawQuery();
Map<String, String> params = new LinkedHashMap<>();
for (String pair : query.split("&")) {
String[] parts = pair.split("=", 2);
assertNull(params.put(parts[0], URLDecoder.decode(parts[1], StandardCharsets.UTF_8)));
}
assertEquals(FileAccessSignature.sign(name, "service", "v1", NOW, KEY), params.get("sign"));
assertEquals(name, params.get("fileName"));
assertTrue(query.substring(query.lastIndexOf('&') + 1).startsWith("fileName="));
}
}
}
@Test void malformedRequestsAndAllDuplicateFieldsAreRejected() {
var guard = new FileAccessGuard(properties("enforce"), null, clock);
for (String field : List.of("fileName", "appId", "kid", "ts", "sign")) {
var request = signed("a.jpg", "mini", NOW);
request.setParameter(field, request.getParameter(field), request.getParameter(field));
assertEquals(400, guard.evaluate(request).status(), field);
}
for (String field : List.of("appId", "kid", "ts", "sign")) {
var request = signed("a.jpg", "mini", NOW); request.removeParameter(field);
assertEquals(400, guard.evaluate(request).status(), field);
}
for (String timestamp : List.of("-1", "1.5", "001", "NaN", "9999999999999")) {
var request = signed("a.jpg", "mini", NOW); request.setParameter("ts", timestamp);
assertEquals(400, guard.evaluate(request).status());
}
var request = signed("a.jpg", "unknown", NOW);
assertEquals(403, guard.evaluate(request).status());
request = signed("a.jpg", "mini", NOW - 1790);
assertEquals(10, guard.evaluate(request).cacheSeconds());
request.addHeader("User-Agent", "Dart/3.6 (dart:io)");
request.setParameter("sign", "0".repeat(64));
assertEquals(403, guard.evaluate(request).status());
}
@Test void keyRotationAndConfiguredPrefixesWorkWithoutChangingStoredUrls() {
var p = properties("enforce");
p.setPublicBaseUrl("https://files.example/gateway/api");
p.getClients().get("mini").getKeys().put("v2", KEY);
var guard = new FileAccessGuard(p, null, clock);
var request = signed("a.jpg", "mini", NOW); request.setParameter("kid", "v2");
request.setParameter("sign", FileAccessSignature.sign("a.jpg", "mini", "v2", NOW, KEY));
assertEquals(200, guard.evaluate(request).status());
assertEquals(200, guard.evaluate(signed("a.jpg", "mini", NOW)).status());
p.getClients().get("mini").setActiveKid("v2"); p.getClients().get("mini").getKeys().remove("v1");
assertEquals(403, guard.evaluate(signed("a.jpg", "mini", NOW)).status());
String raw = "https://FILES.example:443/gateway/api/admin/sys-file/oss/file?fileName=a.apk";
assertTrue(new FileAccessSigner(p, clock).signUrl(raw, "package").endsWith("fileName=a.apk"));
assertTrue(new FileAccessSigner(p, clock).signUrl(raw, "package").contains("sign="));
}
}
@@ -0,0 +1,53 @@
package com.bsd.pjcloud.admin.service.file;
import com.amazonaws.services.s3.model.S3Object;
import com.bsd.pjcloud.admin.api.entity.SysFile;
import com.bsd.pjcloud.admin.mapper.SysFileMapper;
import com.bsd.pjcloud.admin.service.impl.SysFileServiceImpl;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.file.core.FileTemplate;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.ByteArrayInputStream;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FileReadTest {
@Test void missingFilesAre404AndTenantScopeIsRestored() {
var template = mock(FileTemplate.class); var mapper = mock(SysFileMapper.class);
var service = new SysFileServiceImpl(template, null, null, null, null); ReflectionTestUtils.setField(service, "baseMapper", mapper);
TenantContextHolder.setTenantId(42L); TenantContextHolder.setTenantSkip(false);
try {
var response = new MockHttpServletResponse(); service.getFile("missing.jpg", response);
assertEquals(404, response.getStatus()); assertEquals("no-store", response.getHeader("Cache-Control"));
assertEquals(42L, TenantContextHolder.getTenantId()); assertFalse(TenantContextHolder.getTenantSkip()); verifyZeroInteractions(template);
} finally { TenantContextHolder.clear(); }
}
@Test void bytesAndReadFailuresHaveCorrectStatus() throws Exception {
var template = mock(FileTemplate.class); var mapper = mock(SysFileMapper.class);
var service = new SysFileServiceImpl(template, null, null, null, null); ReflectionTestUtils.setField(service, "baseMapper", mapper);
var file = new SysFile(); file.setBucketName("bucket"); file.setDir("dir"); file.setOriginal("a.jpg"); file.setHash("hash");
when(mapper.selectOne(any())).thenReturn(file);
var object = new S3Object(); object.setObjectContent(new ByteArrayInputStream(new byte[]{1,2,3}));
when(template.getObject("bucket", "dir", "a.jpg")).thenReturn(object);
var response = new MockHttpServletResponse(); service.getFile("a.jpg", response);
assertArrayEquals(new byte[]{1,2,3}, response.getContentAsByteArray()); assertEquals("hash", response.getHeader("Hash"));
when(template.getObject("bucket", "dir", "a.jpg")).thenThrow(new RuntimeException("storage offline"));
response = new MockHttpServletResponse(); service.getFile("a.jpg", response); assertEquals(500, response.getStatus());
}
@Test void databaseFailureAlsoRestoresAnAlreadySkippedTenantScope() {
var mapper = mock(SysFileMapper.class);
var service = new SysFileServiceImpl(mock(FileTemplate.class), null, null, null, null);
ReflectionTestUtils.setField(service, "baseMapper", mapper);
when(mapper.selectOne(any())).thenThrow(new RuntimeException("database unavailable"));
TenantContextHolder.setTenantId(42L); TenantContextHolder.setTenantSkip(true);
try {
var response = new MockHttpServletResponse(); service.getFile("a.jpg", response);
assertEquals(500, response.getStatus()); assertEquals("no-store", response.getHeader("Cache-Control"));
assertEquals(42L, TenantContextHolder.getTenantId()); assertTrue(TenantContextHolder.getTenantSkip());
} finally { TenantContextHolder.clear(); }
}
}
@@ -0,0 +1,186 @@
package com.bsd.pjcloud.admin.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.DeptExportDTO;
import com.bsd.pjcloud.admin.api.vo.DeptExcelVO;
import com.bsd.pjcloud.admin.mapper.SysDeptMapper;
import com.bsd.pjcloud.admin.service.SysFileService;
import com.bsd.pjcloud.common.asynctask.service.*;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.excel.config.ExcelConfigProperties;
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class SysDeptBatchExportTest {
@Test
void asyncUsesBoundedPagesAndSubmittedFilterAndStyleSnapshots() throws Exception {
Harness harness = new Harness(401);
var query = new DeptExportDTO();
query.setDeptName("部门");
AsyncTaskResultAction action = harness.submit(query);
query.setDeptName("已修改");
harness.properties.getExportStyle().getHeader().setBackgroundColor("112233");
var reporter = mock(AsyncTaskProgressReporter.class);
AsyncTaskResult result = action.execute(reporter);
assertEquals("部门管理.xlsx", result.fileName());
assertEquals("https://files.example.com/depts.xlsx", result.fileUrl());
assertEquals(List.of(1L, 2L, 3L, 4L), harness.pages.stream().map(Page::getCurrent).toList());
for (Page<DeptExcelVO> page : harness.pages) {
assertEquals(200, page.getSize());
assertEquals(200L, page.maxLimit());
assertEquals(page.getCurrent() == 1, page.searchCount());
assertEquals(List.of("sort_order", "dept_id"), page.orders().stream().map(order -> order.getColumn()).toList());
}
assertTrue(harness.filters.stream().allMatch("部门"::equals));
verify(reporter).updateTotal(5, "共 401 个部门,正在生成 Excel");
verify(reporter, times(4)).advance(anyString());
verify(harness.files).uploadFile(any(), eq("export"), isNull(), eq("excel"));
assertWorkbook(harness.uploaded, 401);
}
@Test
void legacyGetExportUsesTheSameBatchingAndStyledColumns() throws Exception {
Harness harness = new Harness(201);
var response = new MockHttpServletResponse();
harness.service.export(response, new DeptExportDTO());
assertEquals(BatchExportSupport.CONTENT_TYPE, response.getContentType());
assertTrue(response.getHeader("Content-Disposition").endsWith(".xlsx"));
assertEquals(3, harness.pages.size());
verifyNoMoreInteractions(harness.tasks, harness.files);
assertWorkbook(response.getContentAsByteArray(), 201);
}
@Test
void emptyScopeDoesNotCreateOrUploadAnExportFile() {
Harness harness = new Harness(0);
var response = new MockHttpServletResponse();
var failure = assertThrows(AsyncTaskException.class,
() -> harness.service.export(response, new DeptExportDTO()));
assertEquals("当前筛选条件没有可导出的部门", failure.getMessage());
assertEquals(0, response.getContentAsByteArray().length);
assertFalse(response.isCommitted());
verifyNoMoreInteractions(harness.files);
}
@Test
void excessRowsAndChangesDuringPagingFailBeforeReturningAFile() {
Harness oversized = new Harness((long) BatchExcelExportTemplate.calculateMaxDetailRowCount(1, 0) + 1);
var response = new MockHttpServletResponse();
assertTrue(assertThrows(AsyncTaskException.class,
() -> oversized.service.export(response, new DeptExportDTO())).getMessage().contains("超过单工作表"));
assertEquals(0, response.getContentAsByteArray().length);
Harness changed = new Harness(401);
doAnswer(call -> {
Page<DeptExcelVO> page = call.getArgument(0);
List<DeptExcelVO> rows = page.getCurrent() == 1 ? List.of(row(0)) : List.of();
return new Page<DeptExcelVO>(page.getCurrent(), page.getSize()).setTotal(401).setRecords(rows);
}).when(changed.mapper).selectExportPageByScope(any(), any(), any());
var changedResponse = new MockHttpServletResponse();
assertEquals("导出期间部门数据发生变化,请重试", assertThrows(AsyncTaskException.class,
() -> changed.service.export(changedResponse, new DeptExportDTO())).getMessage());
assertEquals(0, changedResponse.getContentAsByteArray().length);
verifyNoMoreInteractions(changed.files);
}
@Test
void failedUploadDoesNotReportASuccessfulResult() throws Exception {
Harness harness = new Harness(1);
when(harness.files.uploadFile(any(), any(), any(), any())).thenReturn(R.failed("保存失败"));
var action = harness.submit(new DeptExportDTO());
assertEquals("导出文件保存失败,请重试", assertThrows(AsyncTaskException.class,
() -> action.execute(mock(AsyncTaskProgressReporter.class))).getMessage());
}
private void assertWorkbook(byte[] bytes, int rows) throws Exception {
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
var sheet = workbook.getSheetAt(0);
List<String> headers = new ArrayList<>();
for (Cell cell : sheet.getRow(0)) headers.add(cell.getStringCellValue());
assertEquals(List.of("上级部门", "部门名称", "排序值"), headers);
assertEquals(rows, sheet.getLastRowNum());
assertEquals("上级部门", sheet.getRow(1).getCell(0).getStringCellValue());
assertEquals("部门" + (rows - 1), sheet.getRow(rows).getCell(1).getStringCellValue());
assertEquals(rows - 1, sheet.getRow(rows).getCell(2).getNumericCellValue());
var header = sheet.getRow(0).getCell(0).getCellStyle();
assertTrue(header.getFont().getBold());
assertEquals("Microsoft YaHei", header.getFont().getFontName());
assertEquals("1F4E78", rgb(header.getFillForegroundXSSFColor().getRGB()));
assertEquals(36F, sheet.getRow(0).getHeightInPoints());
assertEquals(26F, sheet.getRow(1).getHeightInPoints());
assertEquals("F3F6FA", rgb(sheet.getRow(200).getCell(0).getCellStyle().getFillForegroundXSSFColor().getRGB()));
assertEquals("FFFFFF", rgb(sheet.getRow(201).getCell(0).getCellStyle().getFillForegroundXSSFColor().getRGB()));
assertEquals(32 * 256, sheet.getColumnWidth(0));
assertEquals(32 * 256, sheet.getColumnWidth(1));
assertEquals(14 * 256, sheet.getColumnWidth(2));
assertTrue(sheet.getPaneInformation().isFreezePane());
assertEquals("A1:C" + (rows + 1), sheet.getCTWorksheet().getAutoFilter().getRef());
assertFalse(sheet.isDisplayGridlines());
assertTrue(workbook.getNumCellStyles() < 20);
}
}
private String rgb(byte[] bytes) {
return java.util.HexFormat.of().withUpperCase().formatHex(bytes);
}
private static DeptExcelVO row(long index) {
var row = new DeptExcelVO();
row.setParentName("上级部门");
row.setName("部门" + index);
row.setSortOrder((int) index);
return row;
}
private static class Harness {
private final SysDeptMapper mapper = mock(SysDeptMapper.class);
private final SysFileService files = mock(SysFileService.class);
private final AsyncTaskExecutorService tasks = mock(AsyncTaskExecutorService.class);
private final ExcelConfigProperties properties = new ExcelConfigProperties();
private final SysDeptBatchExportService service = new SysDeptBatchExportService(mapper, files, tasks, properties);
private final List<Page<DeptExcelVO>> pages = new ArrayList<>();
private final List<String> filters = new ArrayList<>();
private byte[] uploaded;
private Harness(long total) {
when(mapper.selectExportPageByScope(any(), any(), any())).thenAnswer(call -> {
Page<DeptExcelVO> page = call.getArgument(0);
DeptExportDTO query = call.getArgument(1);
assertNotNull(call.getArgument(2));
pages.add(page);
filters.add(query.getDeptName());
List<DeptExcelVO> rows = new ArrayList<>();
long start = (page.getCurrent() - 1) * page.getSize();
for (long index = start; index < Math.min(start + page.getSize(), total); index++) rows.add(row(index));
return new Page<DeptExcelVO>(page.getCurrent(), page.getSize()).setTotal(total).setRecords(rows);
});
when(files.uploadFile(any(), eq("export"), isNull(), eq("excel"))).thenAnswer(call -> {
MultipartFile file = call.getArgument(0);
uploaded = file.getBytes();
return R.ok(Map.of("url", "https://files.example.com/depts.xlsx"));
});
}
private AsyncTaskResultAction submit(DeptExportDTO query) {
service.submit(query);
var action = ArgumentCaptor.forClass(AsyncTaskResultAction.class);
verify(tasks).submitWithResult(eq("admin:export:dept"), eq("部门导出"), eq(1), action.capture());
return action.getValue();
}
}
}
@@ -0,0 +1,170 @@
package com.bsd.pjcloud.admin.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.DeptExportDTO;
import com.bsd.pjcloud.admin.api.vo.DeptExcelVO;
import com.bsd.pjcloud.admin.mapper.SysDeptMapper;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeInnerInterceptor;
import com.bsd.pjcloud.common.excel.config.ExcelConfigProperties;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.LocalCacheScope;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.ByteArrayInputStream;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** 运行真实部门 SQL 和租户、数据权限、分页插件,检查权限在计数和分页前生效。 */
class SysDeptExportSqlTest {
private SqlSession session;
private SysDeptMapper mapper;
private boolean allAccess = true;
private List<Long> departments = List.of();
private String username;
@BeforeEach
void setup() throws Exception {
var source = new UnpooledDataSource("org.h2.Driver",
"jdbc:h2:mem:dept_export_" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", "");
var configuration = new MybatisConfiguration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), source));
configuration.setMapUnderscoreToCamelCase(true);
configuration.setLocalCacheScope(LocalCacheScope.STATEMENT);
var plugins = new MybatisPlusInterceptor();
plugins.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override public Expression getTenantId() { return new LongValue(7L); }
}));
var scope = new DataScopeInnerInterceptor();
scope.setDataScopeHandle(dataScope -> {
dataScope.getDeptList().addAll(departments);
dataScope.setUsername(username);
return allAccess;
});
plugins.addInnerInterceptor(scope);
plugins.addInnerInterceptor(new PaginationInnerInterceptor());
configuration.addInterceptor(plugins);
String resource = "mapper/SysDeptMapper.xml";
try (var input = getClass().getClassLoader().getResourceAsStream(resource)) {
assertNotNull(input);
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
session = new SqlSessionFactoryBuilder().build(configuration).openSession(true);
mapper = session.getMapper(SysDeptMapper.class);
try (var statement = session.getConnection().createStatement()) {
statement.execute("CREATE TABLE sys_dept(dept_id BIGINT, parent_id BIGINT, tenant_id BIGINT, "
+ "name VARCHAR(100), sort_order INT, create_by VARCHAR(50), del_flag VARCHAR(1), "
+ "PRIMARY KEY(tenant_id, dept_id))");
statement.execute("INSERT INTO sys_dept VALUES "
+ "(10,0,7,'总部',0,'admin','0'),(11,10,7,'人事一部',1,'alice','0'),"
+ "(12,10,7,'人事二部',1,'bob','0'),(20,0,7,'财务部',1,'alice','0'),"
+ "(30,10,7,'已删除部门',1,'bob','1'),(31,30,7,'孤立部门',2,'charlie','0'),"
+ "(32,999,7,'找不到上级',2,'charlie','0'),(33,99,7,'跨租户孤立部门',2,'charlie','0'),"
+ "(10,0,8,'其他租户总部',0,'admin','0'),(11,10,8,'其他租户人事一部',1,'alice','0'),"
+ "(99,0,8,'外租户上级',0,'admin','0')");
}
}
@AfterEach
void close() {
if (session != null) session.close();
}
@Test
void allAccessStillExcludesOtherTenantsAndDeletedDepartmentsAndResolvesParents() {
Page<DeptExcelVO> page = fetch(null, 1, 20);
assertEquals(7, page.getTotal());
assertEquals(List.of("总部", "人事一部", "人事二部", "财务部", "孤立部门", "找不到上级", "跨租户孤立部门"), names(page));
assertEquals(List.of("根部门", "总部", "总部", "根部门", "根部门", "根部门", "根部门"),
page.getRecords().stream().map(DeptExcelVO::getParentName).toList());
}
@Test
void scopedDepartmentsAndNameFilterApplyBeforeCountAndPagination() {
allAccess = false;
departments = List.of(11L, 12L, 20L);
Page<DeptExcelVO> first = fetch("人事", 1, 1);
Page<DeptExcelVO> second = fetch("人事", 2, 1);
assertEquals(2, first.getTotal());
assertEquals(List.of("人事一部"), names(first));
assertEquals(List.of("人事二部"), names(second));
assertEquals("总部", first.getRecords().get(0).getParentName());
assertTrue(fetch("总部", 1, 10).getRecords().isEmpty());
}
@Test
void selfAndDepartmentScopesCombineWithoutExportingDeletedRows() {
allAccess = false;
username = "bob";
assertEquals(List.of("人事二部"), names(fetch(null, 1, 20)));
departments = List.of(11L);
assertEquals(List.of("人事一部", "人事二部"), names(fetch(null, 1, 20)));
}
@Test
void noDataScopeReturnsNoRowsOrCount() {
allAccess = false;
Page<DeptExcelVO> page = fetch(null, 1, 20);
assertEquals(0, page.getTotal());
assertTrue(page.getRecords().isEmpty());
}
@Test
void realExportKeepsScopedRowsInStableOrderAcrossMultipleBatches() throws Exception {
allAccess = false;
username = "exporter";
try (var insert = session.getConnection().prepareStatement(
"INSERT INTO sys_dept VALUES (?,10,7,?,1,'exporter','0')")) {
for (int index = 0; index < 401; index++) {
insert.setLong(1, 1000L + index);
insert.setString(2, "批量部门" + index);
insert.addBatch();
}
insert.executeBatch();
}
var service = new SysDeptBatchExportService(mapper, null, null, new ExcelConfigProperties());
var query = new DeptExportDTO();
query.setDeptName("批量部门");
var response = new MockHttpServletResponse();
service.export(response, query);
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(response.getContentAsByteArray()))) {
var sheet = workbook.getSheetAt(0);
assertEquals(401, sheet.getLastRowNum());
for (int index = 0; index < 401; index++) {
assertEquals("总部", sheet.getRow(index + 1).getCell(0).getStringCellValue());
assertEquals("批量部门" + index, sheet.getRow(index + 1).getCell(1).getStringCellValue());
}
}
}
private Page<DeptExcelVO> fetch(String name, long current, long size) {
var query = new DeptExportDTO();
query.setDeptName(name);
Page<DeptExcelVO> page = new Page<>(current, size);
page.addOrder(OrderItem.asc("sort_order"), OrderItem.asc("dept_id"));
return mapper.selectExportPageByScope(page, query, DataScope.of());
}
private List<String> names(Page<DeptExcelVO> page) {
return page.getRecords().stream().map(DeptExcelVO::getName).toList();
}
}
@@ -0,0 +1,244 @@
package com.bsd.pjcloud.admin.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.SysLogExportDTO;
import com.bsd.pjcloud.admin.api.entity.SysLog;
import com.bsd.pjcloud.admin.mapper.SysLogMapper;
import com.bsd.pjcloud.admin.service.SysFileService;
import com.bsd.pjcloud.common.asynctask.service.*;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.excel.config.ExcelConfigProperties;
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.time.LocalDateTime;
import java.util.*;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class SysLogBatchExportTest {
private static final long FIRST_ID = 90071992547409930L;
private static final LocalDateTime START = LocalDateTime.of(2026, 10, 29, 0, 0);
private static final LocalDateTime END = START.plusDays(1).minusSeconds(1);
@BeforeAll
static void tableInfo() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), SysLog.class);
}
@Test
void asyncUsesCursorBatchesAndImmutableFiltersAndStyles() throws Exception {
Harness harness = new Harness(401);
var query = query();
var action = harness.submit(query);
query.setLogType("9");
query.setServiceId("changed");
query.getCreateTime()[0] = START.minusDays(10);
query.getIds()[0] = 1L;
harness.properties.getExportStyle().getHeader().setBackgroundColor("112233");
var reporter = mock(AsyncTaskProgressReporter.class);
AsyncTaskResult result = action.execute(reporter);
assertEquals("操作日志.xlsx", result.fileName());
assertEquals("https://files.example.com/logs.xlsx", result.fileUrl());
assertEquals(4, harness.pages.size());
for (int index = 0; index < harness.pages.size(); index++) {
Page<SysLog> page = harness.pages.get(index);
assertEquals(1, page.getCurrent(), "游标分页不使用深 OFFSET");
assertEquals(200, page.getSize());
assertEquals(200L, page.maxLimit());
assertEquals(index == 0, page.searchCount());
LambdaQueryWrapper<SysLog> wrapper = harness.queries.get(index);
String sql = wrapper.getSqlSegment();
assertTrue(sql.contains("ORDER BY id DESC"), sql);
assertEquals("0", parameter(wrapper, "log_type", "="));
assertEquals("sms", parameter(wrapper, "service_id", "="));
assertEquals(START, parameter(wrapper, "create_time", ">="));
assertEquals(END, parameter(wrapper, "create_time", "<="));
assertTrue(wrapper.getParamNameValuePairs().containsValue(FIRST_ID));
}
assertNull(parameter(harness.queries.get(0), "id", "<"));
assertEquals(FIRST_ID - 199, parameter(harness.queries.get(1), "id", "<"));
assertEquals(FIRST_ID - 399, parameter(harness.queries.get(2), "id", "<"));
assertEquals(FIRST_ID - 400, parameter(harness.queries.get(3), "id", "<"));
verify(reporter).updateTotal(5, "共 401 条日志,正在生成 Excel");
verify(reporter, times(4)).advance(anyString());
assertWorkbook(harness.uploaded, 401);
}
@Test
void legacyExportUsesTheSameBatchesAndStylesAndBlankFiltersExportAllTypes() throws Exception {
Harness harness = new Harness(201);
var query = new SysLogExportDTO();
query.setLogType("");
query.setServiceId("");
query.setCreateTime(new LocalDateTime[]{null}); // GET createTime=
query.setIds(new Long[0]);
var response = new MockHttpServletResponse();
harness.service.export(response, query);
assertEquals(BatchExportSupport.CONTENT_TYPE, response.getContentType());
assertTrue(response.getHeader("Content-Disposition").endsWith(".xlsx"));
assertEquals(3, harness.pages.size());
assertEquals("ORDER BY id DESC", harness.queries.get(0).getSqlSegment().trim());
verifyNoMoreInteractions(harness.tasks, harness.files);
assertWorkbook(response.getContentAsByteArray(), 201);
}
@Test
void emptyAndOversizedExportsDoNotReturnPartialWorkbooks() {
Harness empty = new Harness(0);
var response = new MockHttpServletResponse();
assertEquals("当前筛选条件没有可导出的日志", assertThrows(AsyncTaskException.class,
() -> empty.service.export(response, new SysLogExportDTO())).getMessage());
assertEquals(0, response.getContentAsByteArray().length);
assertFalse(response.isCommitted());
verifyNoMoreInteractions(empty.files);
Harness oversized = new Harness((long) BatchExcelExportTemplate.calculateMaxDetailRowCount(1, 0) + 1);
assertTrue(assertThrows(AsyncTaskException.class,
() -> oversized.service.export(response, new SysLogExportDTO())).getMessage().contains("超过单工作表"));
assertEquals(0, response.getContentAsByteArray().length);
verifyNoMoreInteractions(oversized.files);
}
@Test
void deletionDuringExportAndUploadFailureDoNotReturnSuccessfulResults() throws Exception {
Harness changed = new Harness(401);
changed.availableRows = 200;
var response = new MockHttpServletResponse();
assertEquals("导出期间日志数据发生变化,请重试", assertThrows(AsyncTaskException.class,
() -> changed.service.export(response, new SysLogExportDTO())).getMessage());
assertEquals(0, response.getContentAsByteArray().length);
Harness failedUpload = new Harness(1);
when(failedUpload.files.uploadFile(any(), any(), any(), any())).thenReturn(R.failed("保存失败"));
var action = failedUpload.submit(new SysLogExportDTO());
assertEquals("导出文件保存失败,请重试", assertThrows(AsyncTaskException.class,
() -> action.execute(mock(AsyncTaskProgressReporter.class))).getMessage());
}
@Test
void invalidRangesFailBeforeSubmittingOrReadingLogs() {
Harness harness = new Harness(1);
for (LocalDateTime[] range : List.of(new LocalDateTime[]{START},
new LocalDateTime[]{START, null}, new LocalDateTime[]{END, START})) {
var query = new SysLogExportDTO();
query.setCreateTime(range);
assertEquals("请选择有效的日志创建时间范围", assertThrows(AsyncTaskException.class,
() -> harness.service.submit(query)).getMessage());
}
verifyNoMoreInteractions(harness.tasks, harness.mapper, harness.files);
}
private void assertWorkbook(byte[] bytes, int rows) throws Exception {
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
var sheet = workbook.getSheetAt(0);
List<String> headers = new ArrayList<>();
for (Cell cell : sheet.getRow(0)) headers.add(cell.getStringCellValue());
assertEquals(List.of("日志编号", "日志类型(0-正常 9-错误)", "日志标题", "创建人", "创建时间",
"操作ip地址", "userAgent", "浏览器", "操作方式", "提交数据", "执行时间", "异常信息", "应用标识"), headers);
assertEquals(rows, sheet.getLastRowNum());
assertEquals(Long.toString(FIRST_ID), sheet.getRow(1).getCell(0).getStringCellValue());
assertEquals(Long.toString(FIRST_ID - rows + 1), sheet.getRow(rows).getCell(0).getStringCellValue());
assertEquals("2026-10-29 00:00:00", sheet.getRow(1).getCell(4).getStringCellValue());
assertEquals("{\"message\":\"中文日志\"}", sheet.getRow(1).getCell(9).getStringCellValue());
var header = sheet.getRow(0).getCell(0).getCellStyle();
assertTrue(header.getFont().getBold());
assertEquals("Microsoft YaHei", header.getFont().getFontName());
assertEquals("1F4E78", rgb(header.getFillForegroundXSSFColor().getRGB()));
assertEquals(36F, sheet.getRow(0).getHeightInPoints());
assertEquals(26F, sheet.getRow(1).getHeightInPoints());
assertEquals("F3F6FA", rgb(sheet.getRow(200).getCell(0).getCellStyle().getFillForegroundXSSFColor().getRGB()));
assertEquals("FFFFFF", rgb(sheet.getRow(201).getCell(0).getCellStyle().getFillForegroundXSSFColor().getRGB()));
assertEquals(24 * 256, sheet.getColumnWidth(0));
assertEquals(50 * 256, sheet.getColumnWidth(9));
assertEquals(60 * 256, sheet.getColumnWidth(11));
assertTrue(sheet.getPaneInformation().isFreezePane());
assertEquals("A1:M" + (rows + 1), sheet.getCTWorksheet().getAutoFilter().getRef());
assertFalse(sheet.isDisplayGridlines());
assertTrue(workbook.getNumCellStyles() < 20);
}
}
private String rgb(byte[] bytes) {
return HexFormat.of().withUpperCase().formatHex(bytes);
}
private static SysLogExportDTO query() {
var query = new SysLogExportDTO();
query.setLogType("0");
query.setServiceId("sms");
query.setCreateTime(new LocalDateTime[]{START, END});
query.setIds(java.util.stream.LongStream.range(0, 401).mapToObj(index -> FIRST_ID - index).toArray(Long[]::new));
return query;
}
private static Object parameter(LambdaQueryWrapper<SysLog> wrapper, String field, String operator) {
var matcher = Pattern.compile("\\b" + field + " " + Pattern.quote(operator)
+ " #\\{ew.paramNameValuePairs.(MPGENVAL\\d+)\\}").matcher(wrapper.getSqlSegment());
return matcher.find() ? wrapper.getParamNameValuePairs().get(matcher.group(1)) : null;
}
private static class Harness {
private final SysLogMapper mapper = mock(SysLogMapper.class);
private final SysFileService files = mock(SysFileService.class);
private final AsyncTaskExecutorService tasks = mock(AsyncTaskExecutorService.class);
private final ExcelConfigProperties properties = new ExcelConfigProperties();
private final SysLogBatchExportService service = new SysLogBatchExportService(mapper, files, tasks, properties);
private final List<Page<SysLog>> pages = new ArrayList<>();
private final List<LambdaQueryWrapper<SysLog>> queries = new ArrayList<>();
private long availableRows;
private byte[] uploaded;
private Harness(long total) {
availableRows = total;
when(mapper.selectPage(any(Page.class), any())).thenAnswer(call -> {
Page<SysLog> page = call.getArgument(0);
LambdaQueryWrapper<SysLog> wrapper = call.getArgument(1);
pages.add(page);
queries.add(wrapper);
Long lastId = (Long) parameter(wrapper, "id", "<");
long start = lastId == null ? 0 : FIRST_ID - lastId + 1;
List<SysLog> rows = new ArrayList<>();
for (long index = start; index < Math.min(start + page.getSize(), availableRows); index++) {
var row = new SysLog();
row.setId(FIRST_ID - index);
row.setLogType("0");
row.setTitle("日志" + index);
row.setCreateTime(START);
row.setParams("{\"message\":\"中文日志\"}");
row.setServiceId("sms");
rows.add(row);
}
return new Page<SysLog>(1, page.getSize()).setTotal(total).setRecords(rows);
});
when(files.uploadFile(any(), eq("export"), isNull(), eq("excel"))).thenAnswer(call -> {
MultipartFile file = call.getArgument(0);
uploaded = file.getBytes();
return R.ok(Map.of("url", "https://files.example.com/logs.xlsx"));
});
}
private AsyncTaskResultAction submit(SysLogExportDTO query) {
service.submit(query);
var action = ArgumentCaptor.forClass(AsyncTaskResultAction.class);
verify(tasks).submitWithResult(eq("admin:export:log"), eq("日志导出"), eq(1), action.capture());
return action.getValue();
}
}
}
@@ -0,0 +1,73 @@
package com.bsd.pjcloud.admin.service.impl;
import com.bsd.pjcloud.admin.api.dto.SysLogExportDTO;
import com.bsd.pjcloud.admin.controller.SysLogController;
import com.bsd.pjcloud.admin.service.SysLogService;
import com.bsd.pjcloud.common.core.jackson.PjcloudJavaTimeModule;
import com.bsd.pjcloud.common.security.annotation.HasPermission;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class SysLogExportControllerTest {
private final SysLogBatchExportService exports = mock(SysLogBatchExportService.class);
private MockMvc mvc;
@BeforeEach
void setup() {
ObjectMapper json = new ObjectMapper().registerModule(new PjcloudJavaTimeModule());
mvc = MockMvcBuilders.standaloneSetup(new SysLogController(mock(SysLogService.class), exports))
.setMessageConverters(new MappingJackson2HttpMessageConverter(json)).build();
}
@Test
void originalGetSupportsEmptyFiltersAndTimeRangeAndIds() throws Exception {
mvc.perform(get("/log/export").param("logType", "").param("createTime", "").param("serviceId", ""))
.andExpect(status().isOk());
verify(exports).export(any(HttpServletResponse.class), any(SysLogExportDTO.class));
reset(exports);
mvc.perform(get("/log/export").param("logType", "9").param("serviceId", "sms")
.param("createTime", "2026-10-29 00:00:00", "2026-10-29 23:59:59")
.param("ids", "90071992547409930", "90071992547409929"))
.andExpect(status().isOk());
var captured = ArgumentCaptor.forClass(SysLogExportDTO.class);
verify(exports).export(any(HttpServletResponse.class), captured.capture());
assertEquals("9", captured.getValue().getLogType());
assertEquals("sms", captured.getValue().getServiceId());
assertArrayEquals(new LocalDateTime[]{LocalDateTime.of(2026, 10, 29, 0, 0),
LocalDateTime.of(2026, 10, 29, 23, 59, 59)}, captured.getValue().getCreateTime());
assertArrayEquals(new Long[]{90071992547409930L, 90071992547409929L}, captured.getValue().getIds());
}
@Test
void asyncAcceptsListDateFormatAndBothRoutesKeepExportPermission() throws Exception {
mvc.perform(post("/log/export-async").contentType(MediaType.APPLICATION_JSON).content("""
{"logType":"0","serviceId":"sms","createTime":["2026-10-29 00:00:00","2026-10-29 23:59:59"]}
""")).andExpect(status().isOk());
var captured = ArgumentCaptor.forClass(SysLogExportDTO.class);
verify(exports).submit(captured.capture());
assertEquals("0", captured.getValue().getLogType());
assertEquals("sms", captured.getValue().getServiceId());
assertEquals(LocalDateTime.of(2026, 10, 29, 23, 59, 59), captured.getValue().getCreateTime()[1]);
assertArrayEquals(new String[]{"sys_log_export"}, SysLogController.class
.getMethod("export", HttpServletResponse.class, SysLogExportDTO.class).getAnnotation(HasPermission.class).value());
assertArrayEquals(new String[]{"sys_log_export"}, SysLogController.class
.getMethod("exportAsync", SysLogExportDTO.class).getAnnotation(HasPermission.class).value());
}
}
@@ -0,0 +1,84 @@
package com.bsd.pjcloud.admin.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import java.sql.PreparedStatement;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 直接解析实际 Mapper,防止导出遗漏页面传入的审核状态。 */
class SysUserExportQueryTest {
private MybatisConfiguration configuration;
private MappedStatement statement;
@BeforeEach
void setup() throws Exception {
configuration = new MybatisConfiguration();
String resource = "mapper/SysUserMapper.xml";
try (var input = getClass().getClassLoader().getResourceAsStream(resource)) {
assertNotNull(input);
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
statement = configuration.getMappedStatement(
"com.bsd.pjcloud.admin.mapper.SysUserMapper.selectVoExportPageByScope");
}
@ParameterizedTest
@ValueSource(strings = {"1", "2", "3"})
void exportRowsAndCountRespectProcessStatusAlongsideOtherFilters(String status) throws Exception {
UserDTO query = new UserDTO();
query.setProcessStatus(status);
query.setUsername("operator");
query.setDeptId(10L);
query.setRole(List.of(20L));
Map<String, Object> parameters = parameters(query);
BoundSql bound = statement.getBoundSql(parameters);
String sql = bound.getSql().replaceAll("\\s+", " ");
assertTrue(sql.contains("u.process_status = ?"), sql);
assertTrue(sql.contains("u.username LIKE ?"), sql);
assertTrue(sql.contains("u.dept_id = ?"), sql);
assertTrue(sql.contains("urole.role_id IN"), sql);
String countSql = new PaginationInnerInterceptor().autoCountSql(new Page<>(), sql);
assertTrue(countSql.contains("u.process_status = ?"), countSql);
PreparedStatement prepared = mock(PreparedStatement.class);
configuration.newParameterHandler(statement, parameters, bound).setParameters(prepared);
List<String> properties = bound.getParameterMappings().stream().map(mapping -> mapping.getProperty()).toList();
int statusIndex = properties.indexOf("query.processStatus");
assertTrue(statusIndex >= 0, properties.toString());
verify(prepared).setString(statusIndex + 1, status);
}
@ParameterizedTest
@NullAndEmptySource
void missingProcessStatusKeepsAllStatuses(String status) {
UserDTO query = new UserDTO();
query.setProcessStatus(status);
BoundSql bound = statement.getBoundSql(parameters(query));
assertFalse(bound.getSql().contains("u.process_status ="));
assertTrue(bound.getParameterMappings().stream()
.noneMatch(mapping -> "query.processStatus".equals(mapping.getProperty())));
}
private Map<String, Object> parameters(UserDTO query) {
Map<String, Object> parameters = new HashMap<>();
parameters.put("query", query);
parameters.put("ids", null);
return parameters;
}
}
@@ -46,6 +46,20 @@ import static org.mockito.Mockito.*;
class SysUserExportStyleTest {
@Test
void asyncRetainsSubmittedProcessStatusForEveryPage() throws Exception {
Harness harness = new Harness(new ExcelConfigProperties());
UserDTO query = new UserDTO();
query.setProcessStatus("2");
harness.submit(query);
query.setProcessStatus("3");
harness.run();
ArgumentCaptor<UserDTO> captured = ArgumentCaptor.forClass(UserDTO.class);
verify(harness.mapper, atLeast(2)).selectVoExportPageByScope(any(), captured.capture(), isNull(), any());
captured.getAllValues().forEach(pageQuery -> assertEquals("2", pageQuery.getProcessStatus()));
}
@Test
void asyncExcludesRequestedColumnsAndPreservesRemainingContentAndStyles() throws Exception {
Harness harness = new Harness(new ExcelConfigProperties());
@@ -263,7 +277,11 @@ class SysUserExportStyleTest {
}
private void submit() {
service.submit(new UserDTO());
submit(new UserDTO());
}
private void submit(UserDTO query) {
service.submit(query);
ArgumentCaptor<AsyncTaskResultAction> captor = ArgumentCaptor.forClass(AsyncTaskResultAction.class);
verify(tasks).submitWithResult(eq("admin:export:user"), eq("用户导出"), eq(1), captor.capture());
action = captor.getValue();
@@ -0,0 +1,29 @@
[
{
"fileName": "a.jpg",
"appId": "mini",
"kid": "v1",
"ts": 1793232000,
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-read\nmini\nv1\n1793232000\nYS5qcGc",
"sign": "348b1a5dd5d20560b7537916a6ec525efd173ccc1ee4af5357ca634f26296eb4"
},
{
"fileName": "照片 +&%.png",
"appId": "admin",
"kid": "v1",
"ts": 1793232000,
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-read\nadmin\nv1\n1793232000\n54Wn54mHICsmJS5wbmc",
"sign": "9cdaa97a00bf333cf7af13cd915147d474367ef66c9a46adba27f2cbf910ea41"
},
{
"fileName": "literal%2Fname.apk",
"appId": "package",
"kid": "v1",
"ts": 1793232000,
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-read\npackage\nv1\n1793232000\nbGl0ZXJhbCUyRm5hbWUuYXBr",
"sign": "a28e0b9ec5854749128b0413f0d97c425b8ee785c0c53b41f4469ee509c37e10"
}
]
@@ -208,7 +208,7 @@ public class MchManageController {
@GetMapping("/page-join")
@PreAuthorize("@pms.hasPermission('wallet_mchManage_view')")
public R<Page<MchManageVo>> getMchManagePageJoin(@ParameterObject Page page, @ParameterObject MchManageDto dto) {
DataScope dataScope = null;
DataScope dataScope = new DataScope().only(true);
return R.ok(mchManageService.scopePageList(page, dto, dataScope));
}
@@ -216,14 +216,15 @@ public class MchManageController {
@GetMapping("/cascade-join")
@PreAuthorize("@pms.hasPermission('wallet_mchManage_view')")
public R<Page<MchManageVo>> cascadeJoin(@ParameterObject Page page, @ParameterObject MchManageDto dto) {
DataScope dataScope = null;
DataScope dataScope = new DataScope().only(true);
return R.ok(mchManageService.cascadeJoin(page, dto, dataScope));
}
@Operation(summary = "分页不要权限下拉", description = "分页不要权限下拉")
@Operation(summary = "分页下拉", description = "分页下拉")
@GetMapping("/select")
@PreAuthorize("@pms.hasPermission('wallet_mchManage_select')")
public R getSelect(@ParameterObject Page page, @ParameterObject MchManageDto dto) {
DataScope dataScope = null;
DataScope dataScope = new DataScope().only(true);
return R.ok(mchManageService.scopePageList(page, dto, dataScope));
}
@@ -17,6 +17,7 @@ import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeInvoiceVo;
import com.bsd.pjcloud.wallet.service.WalletRechargeInvoiceService;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.Data;
import org.springframework.security.access.prepost.PreAuthorize;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -37,7 +38,9 @@ import org.springframework.web.bind.annotation.*;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class WalletRechargeInvoiceController {
/** 共用原模板的分批导出服务。 */
/**
* 共用原模板的分批导出服务。
*/
private final com.bsd.pjcloud.wallet.service.export.WalletBatchExportService batchExportService;
private final WalletRechargeInvoiceService walletRechargeInvoiceService;
@@ -53,6 +56,7 @@ public class WalletRechargeInvoiceController {
@Operation(summary = "重新生成发票", description = "重新生成发票")
@SysLog("重新生成发票")
@PostMapping("/refreshInvoice")
@PreAuthorize("@pms.hasPermission('wallet_invoice_refresh')")
@Idempotent(expireTime = 3, key = "'/walletRechargeInvoice/refreshInvoice/' + #dto.getId()", info = "正在重新生成发票中,请勿重复操作")
public R refreshInvoice(@Valid @RequestBody WalletRechargeInvoiceRefreshDto dto) {
walletRechargeInvoiceService.refreshInvoice(dto);
@@ -77,6 +81,7 @@ public class WalletRechargeInvoiceController {
@GetMapping("/back")
@Operation(summary = "退回作废发票")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_back')")
@Idempotent(expireTime = 3, key = "'/walletRechargeInvoice/back/' + #id", info = "正在退回中,请勿重复操作")
public R backInvoice(@RequestParam("id") Long id) {
walletRechargeInvoiceService.backInvoice(id);
@@ -85,6 +90,7 @@ public class WalletRechargeInvoiceController {
@PostMapping("/bind-invoice")
@Operation(summary = "绑定发票")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_receiptSave')")
@Idempotent(expireTime = 3, key = "'/walletRechargeInvoice/bind-invoice/' + #dto.getApplyId()", info = "正在申请中,请勿重复操作")
public R bindInvoice(@Valid @RequestBody WalletRechargeBindInvoiceDto dto) {
walletRechargeInvoiceService.bindInvoice(dto);
@@ -93,8 +99,10 @@ public class WalletRechargeInvoiceController {
@Operation(summary = "下拉", description = "下拉")
@GetMapping("/select")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeInvoice_select')")
public R select(@ParameterObject Page page, @ParameterObject WalletRechargeBindInvoiceDto dto) {
DataScope dataScope = null;
DataScope dataScope = DataScope.of().only(true);
dataScope.setScopeDeptName("apply_dept_id");
dto.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
return R.ok(walletRechargeInvoiceService.scopePageList(page, dto, dataScope));
}
@@ -103,7 +111,7 @@ public class WalletRechargeInvoiceController {
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeInvoice_view')")
public R getWalletRechargeInvoicePage(@ParameterObject Page page, @ParameterObject WalletRechargeBindInvoiceDto dto) {
DataScope dataScope = DataScope.of();
DataScope dataScope = DataScope.of().only(true);
dataScope.setScopeDeptName("apply_dept_id");
dto.setIsFindUserCheck(true);
return R.ok(walletRechargeInvoiceService.scopePageList(page, dto, dataScope));
@@ -147,7 +155,9 @@ public class WalletRechargeInvoiceController {
batchExportService.invoice(response, dto, page);
}
/** 按当前筛选条件提交后台分批导出。 */
/**
* 按当前筛选条件提交后台分批导出。
*/
@PostMapping("/export-async")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeInvoice_export')")
public R<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportAsync(@ParameterObject Page page, @RequestBody WalletRechargeBindInvoiceDto dto) {
@@ -406,6 +406,9 @@ public class WalletRechargeInvoiceServiceImpl extends ServiceImpl<WalletRecharge
/**
* 生成盖章图片
*/
@org.springframework.beans.factory.annotation.Autowired
private com.bsd.pjcloud.common.core.file.FileAccessSigner fileAccessSigner;
@Override
public String stamp(WalletRechargeInvoiceEntity invoice) {
// 查看有没有盖章
@@ -439,7 +442,7 @@ public class WalletRechargeInvoiceServiceImpl extends ServiceImpl<WalletRecharge
}
try {
// 读取底图
URLConnection connection = new URL(picUrl).openConnection();
URLConnection connection = new URL(fileAccessSigner.signUrl(picUrl, "service")).openConnection();
connection.setConnectTimeout(5000);
connection.setReadTimeout(5000);
BufferedImage baseImage;
@@ -121,7 +121,7 @@ public class WalletThirdZyServiceImpl implements WalletThirdZyService {
dto.setDeptId(mchManageVo.getDeptId());
}
}
List<SysDept> deptList = remoteDeptService.getAllDept(null).getData();
List<SysDept> deptList = remoteDeptService.getInnerAllDept(null).getData();
if (ObjectUtil.isNotEmpty(deptId)) {
deptList = deptList.stream().filter(v -> ObjectUtil.equals(v.getDeptId(), deptId)).toList();
}
@@ -162,7 +162,7 @@ public class WalletThirdZyServiceImpl implements WalletThirdZyService {
return;
}
// 查询部门信息
List<SysDept> deptList = remoteDeptService.getAllDept(null).getData();
List<SysDept> deptList = remoteDeptService.getInnerAllDept(null).getData();
deptList = deptList.stream().filter(v -> ObjectUtil.equals(v.getIsSyncZy(), YesNoEnum.YES.getCode())).toList();
if (ObjectUtil.isEmpty(deptList)) {
log.info("|{}| syncAllRole 错误 deptList为空", TAG);
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Synchronize one file-access key set across every application and build mode."""
import argparse
import base64
import json
import os
from pathlib import Path
import re
import secrets
from urllib.parse import urlsplit
APPS = ('admin', 'h5', 'mini', 'flutter', 'service', 'package')
ENV_FIELD = re.compile(r'^\s*(?:export\s+)?VITE_FILE_ACCESS_(?:APP_ID|KEY_ID|KEY|ALLOWED_ORIGINS)\s*=')
ENV_COMMENT = '# Shared file access settings (all modes; do not commit).'
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--workspace-root', type=Path, default=Path(__file__).resolve().parents[2])
parser.add_argument('--environment', choices=['development', 'test', 'production', 'all'], help=argparse.SUPPRESS)
parser.add_argument('--public-base-url', required=True, help='Public gateway base for this deployment')
parser.add_argument('--allowed-origin', action='append', help='Owned historical file origin; may be repeated')
parser.add_argument('--mode', choices=['observe', 'enforce'], help='Preserve the current mode when omitted')
args = parser.parse_args()
uri = urlsplit(args.public_base_url)
if uri.scheme not in ('http', 'https') or not uri.hostname or uri.username or uri.query or uri.fragment:
parser.error('--public-base-url must be an HTTP(S) base without credentials, query or fragment')
os.umask(0o077)
root = args.workspace_root.resolve()
secret_dir = root / 'pj_service_cloud/.file-access'
secret_dir.mkdir(parents=True, exist_ok=True)
registry = secret_dir / 'keys.json'
stored = json.loads(registry.read_text()) if registry.exists() else {}
if all(app in stored for app in APPS):
keys = {app: stored[app] for app in APPS}
elif isinstance(stored.get('production'), dict):
# Preserve the deployed keys during migration; never rotate them implicitly.
keys = {app: stored['production'].get(app) for app in APPS}
elif stored:
parser.error('Unrecognized key registry; existing keys have not been changed')
else:
keys = {app: base64.b64encode(secrets.token_bytes(32)).decode('ascii') for app in APPS}
for app, value in keys.items():
try:
if not isinstance(value, str) or len(base64.b64decode(value, validate=True)) < 32:
raise ValueError('Invalid key')
except ValueError:
parser.error('Invalid existing key for ' + app + '; refusing to generate a replacement')
config_path = secret_dir / 'config.yml'
try:
config = json.loads(config_path.read_text()) if config_path.exists() else {'file-access': {}}
except json.JSONDecodeError:
parser.error('Preserve the generated JSON-compatible YAML format in config.yml; no files changed')
policy = config.setdefault('file-access', {})
defaults = {'mode': 'observe', 'block-browser-navigation': True, 'ttl-seconds': 1800,
'package-ttl-seconds': 86400, 'future-skew-seconds': 300,
'legacy-dart': {'enabled': True, 'extensions': ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico']}}
for name, value in defaults.items():
policy.setdefault(name, value)
if args.mode:
policy['mode'] = args.mode
policy['public-base-url'] = args.public_base_url.rstrip('/')
if args.allowed_origin is not None:
policy['allowed-origins'] = list(dict.fromkeys(args.allowed_origin))
policy.setdefault('allowed-origins', [])
clients = policy.setdefault('clients', {})
for app in APPS:
client = clients.setdefault(app, {'active-kid': 'v1'})
client.setdefault('active-kid', 'v1')
client.setdefault('keys', {})['v1'] = keys[app]
def backup(path):
target = secret_dir / 'migration-backup' / path.relative_to(root)
if path.exists() and not target.exists():
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(path.read_bytes())
target.chmod(0o600)
def write(path, content):
if path.exists() and path.read_text() == content:
return
if path.exists():
backup(path)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + '.file-access.tmp')
temporary.write_text(content)
temporary.chmod(0o600)
temporary.replace(path)
print('Updated:', path.relative_to(root))
write(registry, json.dumps(keys, indent=2) + '\n')
write(config_path, json.dumps(config, indent=2) + '\n')
for app, project in {'admin': 'pj_admin_cloud', 'h5': 'pj_h5_cloud', 'mini': 'pj_mini_app_cloud'}.items():
folder = root / project
folder.mkdir(parents=True, exist_ok=True)
variables = {'VITE_FILE_ACCESS_APP_ID': app, 'VITE_FILE_ACCESS_KEY_ID': 'v1',
'VITE_FILE_ACCESS_KEY': keys[app],
'VITE_FILE_ACCESS_ALLOWED_ORIGINS': ','.join(policy['allowed-origins'])}
local = folder / '.env.local'
previous = local.read_text() if local.exists() else ''
remaining = [line for line in previous.splitlines() if not ENV_FIELD.match(line) and line != ENV_COMMENT]
content = '\n'.join(remaining).rstrip()
content = (content + '\n\n' if content else '') + ENV_COMMENT + '\n'
content += ''.join(f'{name}={json.dumps(value)}\n' for name, value in variables.items())
write(local, content)
# Mode-specific Vite files override .env.local. Remove only our four fields.
for old in folder.glob('.env.*.local'):
text = old.read_text()
if not any(ENV_FIELD.match(line) for line in text.splitlines()):
continue
backup(old)
remaining = [line for line in text.splitlines(keepends=True) if not ENV_FIELD.match(line)]
if any(line.strip() and not line.lstrip().startswith('#') for line in remaining):
old.write_text(''.join(remaining))
else:
old.unlink()
print('Removed mode-specific file-access fields:', old.relative_to(root))
flutter_dir = root / 'pj_supermarket_cloud/.file-access'
write(flutter_dir / 'config.json', json.dumps({
'FILE_ACCESS_APP_ID': 'flutter', 'FILE_ACCESS_KEY_ID': 'v1',
'FILE_ACCESS_KEY': keys['flutter'], 'FILE_ACCESS_ALLOWED_ORIGINS': ','.join(policy['allowed-origins']),
}, indent=2) + '\n')
for name in ('development', 'test', 'production'):
for old in (secret_dir / (name + '.yml'), flutter_dir / (name + '.json')):
if not old.exists():
continue
text = old.read_text()
if old.suffix == '.yml':
try:
owned = set(json.loads(text)) == {'file-access'}
except json.JSONDecodeError:
owned = re.findall(r'^([a-zA-Z][\w-]*):', text, re.M) == ['file-access']
else:
owned = set(json.loads(text)).issubset({'FILE_ACCESS_APP_ID', 'FILE_ACCESS_KEY_ID', 'FILE_ACCESS_KEY', 'FILE_ACCESS_ALLOWED_ORIGINS'})
if owned:
backup(old)
old.unlink()
if args.environment:
print('--environment is retained for compatibility; every environment now uses the same keys.')
print('One shared key set is active. Existing keys were retained; migration backups are ignored local files.')
if __name__ == '__main__':
main()
@@ -0,0 +1,91 @@
import base64
import hashlib
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / 'generate-file-access-config.py'
APPS = ('admin', 'h5', 'mini', 'flutter', 'service', 'package')
class SharedFileAccessConfigurationTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.secret_dir = self.root / 'pj_service_cloud/.file-access'
self.secret_dir.mkdir(parents=True)
def run_script(self, *extra, success=True):
result = subprocess.run([sys.executable, str(SCRIPT), '--workspace-root', str(self.root),
'--public-base-url', 'https://files.example/api', *extra],
text=True, capture_output=True, timeout=15)
self.assertEqual(result.returncode == 0, success, result.stderr)
return result
def test_existing_production_keys_are_retained_for_every_build_mode(self):
old = {env: {app: base64.b64encode(hashlib.sha256((env + app).encode()).digest()).decode()
for app in APPS} for env in ('development', 'test', 'production')}
(self.secret_dir / 'keys.json').write_text(json.dumps(old))
for project in ('pj_admin_cloud', 'pj_h5_cloud', 'pj_mini_app_cloud'):
folder = self.root / project
folder.mkdir()
(folder / '.env.local').write_text('VITE_EXISTING_SETTING=keep\n')
(folder / '.env.development.local').write_text('VITE_API_URL=/existing-api\nVITE_FILE_ACCESS_KEY="old"\n')
(folder / '.env.production.local').write_text('# Local file access settings; do not commit.\nVITE_FILE_ACCESS_KEY="old"\n')
for env in old:
(self.secret_dir / (env + '.yml')).write_text(json.dumps({'file-access': {'mode': 'observe'}}))
flutter = self.root / 'pj_supermarket_cloud/.file-access'
flutter.mkdir(parents=True, exist_ok=True)
(flutter / (env + '.json')).write_text(json.dumps({'FILE_ACCESS_KEY': old[env]['flutter']}))
result = self.run_script('--environment', 'development', '--mode', 'enforce')
shared = json.loads((self.secret_dir / 'keys.json').read_text())
self.assertEqual(shared, old['production'])
policy = json.loads((self.secret_dir / 'config.yml').read_text())['file-access']
self.assertEqual(policy['mode'], 'enforce')
for app in APPS:
self.assertEqual(policy['clients'][app]['keys']['v1'], shared[app])
self.assertNotIn(shared[app], result.stdout)
for app, project in (('admin', 'pj_admin_cloud'), ('h5', 'pj_h5_cloud'), ('mini', 'pj_mini_app_cloud')):
folder = self.root / project
self.assertIn(shared[app], (folder / '.env.local').read_text())
self.assertIn('VITE_EXISTING_SETTING=keep', (folder / '.env.local').read_text())
self.assertEqual((folder / '.env.development.local').read_text(), 'VITE_API_URL=/existing-api\n')
self.assertFalse((folder / '.env.production.local').exists())
flutter = self.root / 'pj_supermarket_cloud/.file-access'
self.assertEqual(json.loads((flutter / 'config.json').read_text())['FILE_ACCESS_KEY'], shared['flutter'])
for env in old:
self.assertFalse((self.secret_dir / (env + '.yml')).exists())
self.assertFalse((flutter / (env + '.json')).exists())
self.assertTrue((self.secret_dir / 'migration-backup/pj_service_cloud/.file-access/keys.json').exists())
before = {path: path.read_bytes() for path in self.root.rglob('*') if path.is_file()}
self.run_script('--environment', 'test')
after = {path: path.read_bytes() for path in self.root.rglob('*') if path.is_file()}
self.assertEqual(before, after)
def test_new_workspace_has_one_registry_and_observation_remains_the_default(self):
self.run_script()
keys = json.loads((self.secret_dir / 'keys.json').read_text())
self.assertEqual(set(keys), set(APPS))
self.assertEqual(len(set(keys.values())), len(APPS))
for key in keys.values():
self.assertEqual(len(base64.b64decode(key)), 32)
policy = json.loads((self.secret_dir / 'config.yml').read_text())['file-access']
self.assertEqual(policy['mode'], 'observe')
self.assertTrue(policy['block-browser-navigation'])
def test_invalid_existing_keys_are_never_silently_replaced(self):
original = {app: 'not-a-valid-base64-key' for app in APPS}
registry = self.secret_dir / 'keys.json'
registry.write_text(json.dumps(original))
self.run_script(success=False)
self.assertEqual(json.loads(registry.read_text()), original)
self.assertFalse((self.secret_dir / 'config.yml').exists())
if __name__ == '__main__':
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
[
{
"fileName": "a.jpg",
"appId": "mini",
"kid": "v1",
"ts": 1793232000,
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-read\nmini\nv1\n1793232000\nYS5qcGc",
"sign": "348b1a5dd5d20560b7537916a6ec525efd173ccc1ee4af5357ca634f26296eb4"
},
{
"fileName": "照片 +&%.png",
"appId": "admin",
"kid": "v1",
"ts": 1793232000,
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-read\nadmin\nv1\n1793232000\n54Wn54mHICsmJS5wbmc",
"sign": "9cdaa97a00bf333cf7af13cd915147d474367ef66c9a46adba27f2cbf910ea41"
},
{
"fileName": "literal%2Fname.apk",
"appId": "package",
"kid": "v1",
"ts": 1793232000,
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"canonical": "v1\noss-file-read\npackage\nv1\n1793232000\nbGl0ZXJhbCUyRm5hbWUuYXBr",
"sign": "a28e0b9ec5854749128b0413f0d97c425b8ee785c0c53b41f4469ee509c37e10"
}
]