diff --git a/.gitignore b/.gitignore index 765142c86..7f12e8d17 100644 --- a/.gitignore +++ b/.gitignore @@ -62,9 +62,5 @@ gen_code ### node ### node_modules -### AI 辅助工具目录 ### -.v2c/ -.video_agent/ -.mimosa/ -.cloudbase/ -.zcode/ +# Local file-access credentials +.file-access/ diff --git a/docs/file-access/README.md b/docs/file-access/README.md new file mode 100644 index 000000000..f5de41374 --- /dev/null +++ b/docs/file-access/README.md @@ -0,0 +1,178 @@ +# 文件读取与上传签名接入及发布 + +本次保护 `/admin/sys-file/oss/file` 的全部文件。默认 `observe` 只记录签名校验结果;只有加载完整配置并切到 `enforce` 后,普通裸链接才会被拒绝。旧 Dart 图片兼容是独立开关。客户端密钥可提取、UA 可伪造、有效接口地址仍可被模拟请求使用,这是一层应用级校验,不是用户或租户文件授权。 + +上传入口 `/admin/sys-file/upload`、`/admin/sys-file/inner-upload`、`/admin/sys-file/qn/upload` 同步使用应用签名和服务端文件白名单。上传复用这套配置和密钥,不增加环境密钥组;读取签名不能用于上传。 + +## 配置准备 + +服务端配置见 `config.example.yml`。密钥为至少 32 字节随机数据的 Base64 字符串,HMAC 使用 **Base64 解码后的字节**。不要复用 OAuth 客户端信息或密码加密密钥。 + +在 `pj_service_cloud` 执行以下命令可为并列的五个项目生成匹配的本地配置: + +```bash +python3 scripts/generate-file-access-config.py \ + --public-base-url https://www.pjzhhq.cn/api \ + --allowed-origin https://stgl.pj.gov.cn +``` + +按用户最终要求,所有环境共用一套密钥配置,`admin/h5/mini/flutter/service/package` 各自保留固定密钥;开发、测试、生产不再另建密钥组。只有公网基础地址等部署参数随目标环境填写。脚本统一读取 `.file-access/keys.json`,重复执行不会更换密钥;旧命令的 `--environment` 参数仍接受,但不再切换密钥。 + +迁移时沿用原生产密钥组,避免已构建客户端失效。脚本只更新受管理的文件访问配置字段,保留其他业务环境变量;移除旧 Vite 模式文件中的文件访问字段,避免其覆盖公共配置。旧文件备份位于 Java 项目的 `.file-access/migration-backup/`,不参与构建。当前活动配置均被 Git 忽略: + +首次生成只做一次,其他构建机器和 CI 同步这套现有配置。密钥统一后无需随部署环境重新生成。 + +| 项目 | 本地文件 | 使用方式 | +| --- | --- | --- | +| Java | `.file-access/config.yml` | Nacos 共享配置或 Spring 外部配置 | +| 后台 | `.env.local` | 所有 Vite 构建模式共用 | +| H5 和小程序 | 各项目的 `.env.local` | 所有 Vite 构建模式共用 | +| Flutter | `.file-access/config.json` | 所有构建使用 `--dart-define-from-file=.file-access/config.json` | + +前端的配置项为 `VITE_FILE_ACCESS_APP_ID`、`VITE_FILE_ACCESS_KEY_ID`、`VITE_FILE_ACCESS_KEY`、`VITE_FILE_ACCESS_ALLOWED_ORIGINS`。Flutter 去掉 `VITE_` 前缀。Java 的 `clients` 包含 `admin/h5/mini/flutter/service/package`;`service` 和 `package` 密钥只放服务端。 + +`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`,不拦截。 + +后台会截获本系统文件 `` 链接,通过已有 Blob 下载工具处理;H5/小程序富文本文件链接改走图片预览或下载后打开本地文件。复制出来的接口地址不再承诺可直接在浏览器打开。Flutter 原生下载、Feign 等没有浏览器 Fetch Metadata 的请求仍执行签名/旧 Dart 规则;不支持这些请求头的旧浏览器同样不能靠本规则识别。这项规则用于限制普通浏览器导航,不能防止脚本携带有效签名并模拟请求,也不能阻止已经获准显示的图片被保存。 + +## 上传身份与文件类型 + +现有登录鉴权及 `@Inner` 内部调用限制保留。每次 POST multipart 上传只允许一个名为 `file` 的文件部分,通过下列请求头传递身份,不把上传凭据放进 URL 或业务字段: + +| 请求头 | 内容 | +| --- | --- | +| `X-File-App-Id` | `admin/h5/mini/flutter/service`,`package` 不可上传 | +| `X-File-Key-Id` | 现有 kid,如 `v1` | +| `X-File-Timestamp` | Unix 秒级时间戳 | +| `X-File-Nonce` | 16~64 位字母、数字、`_`、`-`,每次请求重新生成 | +| `X-File-Signature` | HMAC-SHA256 的 64 位小写十六进制 | + +签名原文按 LF 连接,无末尾换行;密钥仍使用 Base64 解码后的字节: + +```text +v1 +oss-file-upload +POST +target +appId +kid +ts +nonce +``` + +`target` 分别为 `upload`、`inner-upload`、`qn-upload`,三种签名不能互换。`inner-upload` 仅接受 `service`。签名用于应用身份和本次上传操作校验,文件内容由服务端独立检查。默认有效 300 秒,允许客户端时间领先最多 60 秒;复用现有匿名校时接口。严格模式下 Redis 原子 `SET NX` 消耗 nonce,重复请求返回 403;Redis 或配置不可用返回 500,不自动放行。不增加 Dart UA 上传兼容。 + +`file-access.upload` 默认策略如下,省略配置时 Java 使用相同默认值: + +| 文件类别 | 白名单后缀 | +| --- | --- | +| 图片 | jpg/jpeg/png/gif/webp/bmp/ico/svg | +| 文档和安装包 | pdf/apk/doc/docx/xls/xlsx/ppt/pptx | +| 文本和压缩包 | txt/csv/zip | +| 音视频 | mp4/mov/webm/avi/mp3/wav/m4a/aac/ogg/amr/flac | +| 仅内部上传额外允许 | log/gz,供终端日志使用 | + +默认单文件最大 200 MiB,仍受现有网关、反向代理和 Servlet 更低限制约束。SVG 最大 1 MiB,只接受静态内容,拒绝脚本、事件、外部引用和实体。服务层在写存储或文件记录前统一校验后缀、文件头及适用的图片尺寸/ZIP 容器结构;APK 检查二进制 AndroidManifest,Office 新格式检查对应容器入口。存储后缀和 MIME 由服务端规范化,不信任客户端 Content-Type。这里的格式检查不等于病毒或文档宏扫描。 + +JPEG、PNG、GIF、BMP、WebP 位图按实际文件头识别和校验:声明后缀与实际格式均须在对应白名单中,符合条件时使用实际格式保存,不修改图片字节。例如名为 `.png` 的合法 WebP 保存为 `.webp`、MIME 为 `image/webp`。声明为 EXE/PDF/SVG 等其他类别的文件不通过这个规则转成图片,SVG 和文档继续执行各自的严格检查。后台单图、多图及七牛图片上传默认支持 WebP。 + +缺少或错误身份、过期、重放为 403;重复或畸形头、多个文件部分为 400;不支持类型或内容不符为 415;超过本策略大小为 413。错误响应禁止缓存。文件类型检查在 `observe` 下仍执行,观察模式只放宽应用签名准入。监控为 `file.upload.requests`,标签 `outcome/app/mode`,不记录密钥、签名和登录 Token。 + +后台 Axios、通用 el-upload 和富文本上传,H5/小程序的 `uni.uploadFile` 拦截器,以及 Flutter `HttpManage` 为本系统上传路径生成新请求头;导入接口和第三方上传地址不追加本协议。`RemoteFileService` 专属 Feign 拦截器为每次内部上传生成 service 签名。上传成功返回值和保存的文件引用继续为稳定原始地址。 + +**已有读取严格模式的环境,先发布客户端及调用文件服务的 Java 服务,再发布 UPMS。** 旧 UPMS 忽略新增头;新版 UPMS 在 `enforce` 下会拒绝没有上传签名的旧网页/服务请求。Java 调用方须随公共依赖一同更新,避免只升级 UPMS。旧 Flutter 当前通过硬件业务接口上传取证和日志,再由 Java 调用文件中心,这条链路无需旧设备立即升级;不要把这一结论扩展到其他直接调用文件中心的旧客户端。必要时切回 `observe` 恢复旧身份请求,文件白名单仍然生效。 + +## 入口与数据约束 + +| 部分 | 接入位置与行为 | +| --- | --- | +| Java 文件入口 | `FileAccessGuard`,`SysFileController`,`SysFileServiceImpl`;租户跳过只作用于文件查询并恢复原状态 | +| Java 内部读取 | `RemoteFileService` 保留 `getFile` 调用,通过编码桥接和专属 Feign 拦截器签名,保留文件名中的字面 `%xx`;钱包、硬件导出/日志/取证通过相同签名读取;票据 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 tests/file-upload.test.cjs +``` + +后台执行 `pnpm build:prod`,H5 执行 `pnpm build:h5`,小程序执行 `pnpm build:mp-weixin`。跨语言固定向量在各项目 `test-vectors/file-access.json` 和 `test-vectors/file-upload.json`,其中的密钥是公开测试数据,不能用于部署。配置生成器回归执行 `python3 -m unittest discover -s scripts/tests`。 + +Java 定向回归: + +```bash +mvn -pl pjcloud-upms/pjcloud-upms-biz,pjcloud-integrated/pjcloud-integrated-biz,pjcloud-wallet/pjcloud-wallet-biz,pjcloud-hardware/pjcloud-hardware-biz,pjcloud-canteen/pjcloud-canteen-biz \ + -am test \ + -Dtest=FileAccessTest,FileReadTest,FileUploadGuardTest,FileUploadPolicyTest,FileUploadControllerTest,AppVersionFileAccessTest,WalletManageBatchExportTest,WalletLedgerBatchExportTest,HardBatchExportFileTest,HardBatchExportQueryTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +Flutter 使用项目 FVM 3.27.4: + +```bash +fvm flutter test --no-pub --dart-define-from-file=.file-access/config.json \ + test/file_access_test.dart test/file_upload_test.dart test/package_download_test.dart \ + test/face_sync_plan_test.dart test/face_sync_pipeline_test.dart \ + test/face_sync_failure_store_test.dart test/face_readiness_cache_test.dart \ + test/customer_display_balance_test.dart test/customer_display_balance_widget_test.dart +fvm flutter build apk --release --no-pub --dart-define-from-file=.file-access/config.json +``` + +验收结果与待现场验证项见 `verification.md`。不要把本地构建成功或源码中的 Dart UA 推断视为旧安装包验收。 diff --git a/docs/file-access/config.example.yml b/docs/file-access/config.example.yml new file mode 100644 index 000000000..12e7d7509 --- /dev/null +++ b/docs/file-access/config.example.yml @@ -0,0 +1,37 @@ +# 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] + upload: + ttl-seconds: 300 + max-file-bytes: 209715200 # 200 MiB; gateway/servlet limits may be lower + allowed-extensions: [jpg, jpeg, png, gif, webp, bmp, ico, svg, pdf, apk, doc, docx, xls, xlsx, ppt, pptx, txt, csv, zip, mp4, mov, webm, avi, mp3, wav, m4a, aac, ogg, amr, flac] + internal-extensions: [log, gz] # service-signed inner-upload only + 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:}'} diff --git a/docs/file-access/verification.md b/docs/file-access/verification.md new file mode 100644 index 000000000..6de313387 --- /dev/null +++ b/docs/file-access/verification.md @@ -0,0 +1,130 @@ +# 五端文件签名验证记录 + +验证日期: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 中的统一路径为准。本文前面提到的分环境配置路径属于历史验证记录,不再作为当前操作指引。 + +## 上传身份与文件白名单补充 + +2026-10-29 在现有读取协议和同一套固定密钥上增加上传身份校验。普通、内部、七牛三个文件中心上传入口均接入,读取签名不能用于上传;上传签名绑定 POST、目标入口、appId、kid、时间和一次性 nonce。默认 5 分钟有效,严格模式通过 Redis 原子占用 nonce 防止重放;内部上传仅接受 service 身份,Dart UA 不作为上传凭据。 + +文件白名单覆盖图片、PDF、APK、Office、TXT/CSV、ZIP 和常用音视频;仅内部上传额外允许 LOG/GZ。服务层在存储前检查后缀、文件头及适用的容器结构、图片尺寸和静态 SVG 内容,并规范存储扩展名与 MIME。默认最大 200 MiB,SVG 最大 1 MiB,现有网关/Servlet 更低限制仍适用。观察模式只放宽签名准入,文件类型检查始终执行。格式识别不代表病毒或文档宏扫描。 + +| 验证 | 本次结果 | +| --- | --- | +| Java 上传测试 | 17 项通过,覆盖跨语言向量、入口隔离、重复头、过期、Dart UA 不放行、40 个并发请求只有一次 nonce 成功、Redis/错误配置失败关闭、实际 Feign multipart 编码、MockMvc 存储前拒绝及观察模式白名单 | +| 文件格式 | 覆盖常见图片、PDF、文本、Office 容器、ZIP、APK;拒绝改后缀/伪造 MIME、路径穿越、恶意 SVG、外部实体及过深 XML;最新构建的实际 Flutter Release APK 通过格式校验 | +| Java 相关回归 | 52 项通过,覆盖原文件读取、版本地址、日志导出、钱包导出、硬件导出;UPMS、wallet、hardware、integrated、canteen 及所需公共模块编译通过 | +| 后台 | 31 项通过,执行实际 Axios 拦截器、通用上传和富文本上传 setup;保留登录/租户头和 multipart 业务字段,失败文件不阻塞同批成功回填,签名不写进保存的富文本 | +| H5、小程序 | 各 21 项通过,执行实际 uni.uploadFile 拦截器;覆盖上传向量、每次请求新 nonce、校时及第三方/导入路径不签名 | +| Flutter | 签名、上传、安装包和兼容模式定向回归 21 项通过、2 项条件跳过;本次修改的文件工具、HTTP 管理器和上传测试定向 analyze 无诊断 | +| 构建 | 后台生产、H5、微信小程序及 Android Release 均通过;Flutter 使用 FVM 3.27.4 和 `.file-access/config.json`,APK 约 101.9 MB | +| 配置生成器 | 4 项通过,确认只有一套环境共用密钥、原密钥不变、默认上传策略补齐、已有定制白名单和大小限制不被覆盖 | +| TypeScript | 后台全量仍有原有 6 条解析错误,H5 仍有原有 4 条解析错误,小程序仍有原有 17 条 TS6504;后台上传相关组件及其依赖与 HEAD 基线比较均为 115 条既有诊断,无新增诊断,不能表述为全量类型检查通过 | +| 源码与密钥 | 五个项目 Git 候选文件未发现当前活动密钥,保留并发的日志导出、Flutter 调试兼容等改动;Diff 空白检查通过 | + +日志保存在本机 `/tmp/pj-upload-security-work/`。本次上传改造没有发布到运行中的 Java 服务,也没有修改 Nacos 或更换密钥。发布后的真实 multipart 联调、微信真机和旧 Flutter 设备验证仍需执行;MockMvc、内存 Feign 和本地构建不能代替现场验收。 + +已有 `enforce` 环境先发布客户端及携带新公共依赖的 Java 调用服务,再发布 UPMS,避免仅更新 UPMS 后拒绝旧上传请求。当前旧 Flutter 取证/日志先经过硬件业务接口,再由 Java 调用文件中心,服务端补签后不要求这些旧设备立即升级。`observe` 会允许旧上传身份请求,切到 `enforce` 才真正拦截未签名上传;白名单在两种模式下均有效。发布配置与详细头协议见 README 的“上传身份与文件类型”。 + +## 普通位图后缀兼容补充 + +2026-10-29 复现商户 Logo 上传失败:用户提供的 `.png` 文件实际为 RIFF/VP8 WebP,800×440、17,436 字节,独立图片解码验证正常。旧规则按 PNG 文件头判断,因此返回 415。 + +现对声明为 JPG/JPEG/PNG/GIF/BMP/WebP 的文件先识别实际位图格式,检查实际格式也在白名单后再执行对应内容校验,保存时使用实际扩展名和 MIME。原图片字节不变;限制白名单仍生效,其他文件类别不通过这个规则自动转为图片。后台单图、多图和七牛图片上传默认加入 WebP,并让失败的 Promise 交给 Element Plus 统一处理,避免失败后继续触发成功提示。 + +- Java 32 项定向测试通过,含这张原图、公开合成 WebP、严格白名单、脚本/文档不自动转为图片、三个上传入口的 `.webp` 存储结果、原读取鉴权及安装包回归。 +- 后台 30 项定向测试通过,含三个图片组件的 WebP 选择、错误提示及原始返回地址保存。 +- 后台生产构建通过;上传相关组件及其依赖的 TypeScript 诊断由既有 115 条减为 114 条,去掉类型展开差异后无新增诊断。 +- 实际图只通过本机可选测试参数读取,没有作为仓库测试素材提交。验证日志在 `/tmp/pj-upload-image-format-work/`。 + +后端识别逻辑需要发布或重启更新后的 UPMS 才会生效;本次未更改运行环境、密钥或 Nacos。 diff --git a/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppUserController.java b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppUserController.java index 7639860ef..04e48600b 100644 --- a/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppUserController.java +++ b/pjcloud-app-server/pjcloud-app-server-biz/src/main/java/com/bsd/pjcloud/app/controller/AppUserController.java @@ -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)); } diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/config/FileAccessConfiguration.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/config/FileAccessConfiguration.java new file mode 100644 index 000000000..eb0f0bb99 --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/config/FileAccessConfiguration.java @@ -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); } +} diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java new file mode 100644 index 000000000..688e46700 --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java @@ -0,0 +1,95 @@ +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 allowedOrigins = new ArrayList<>(); + private Map clients = new LinkedHashMap<>(); + private LegacyDart legacyDart = new LegacyDart(); + private Upload upload = new Upload(); + + @Data + public static class Upload { + private long ttlSeconds = 300; + private long maxFileBytes = 200L * 1024 * 1024; + private Set allowedExtensions = new HashSet<>(List.of("jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", "svg", + "pdf", "apk", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv", "zip", + "mp4", "mov", "webm", "avi", "mp3", "wav", "m4a", "aac", "ogg", "amr", "flac")); + private Set internalExtensions = new HashSet<>(List.of("log", "gz")); + } + + @Data + public static class Client { + private String activeKid = "v1"; + private Map keys = new LinkedHashMap<>(); + } + + @Data + public static class LegacyDart { + private boolean enabled = true; + private Set 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 (upload == null || upload.ttlSeconds < 60 || upload.ttlSeconds > 1800 || upload.maxFileBytes <= 0 + || upload.maxFileBytes > 1024L * 1024 * 1024 || upload.allowedExtensions == null || upload.internalExtensions == null) { + throw new IllegalStateException("Invalid file-access upload 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; } + } +} diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessSignature.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessSignature.java new file mode 100644 index 000000000..33d885f1f --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessSignature.java @@ -0,0 +1,41 @@ +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) { + return hmac(canonical(fileName, appId, kid, timestamp), base64Key); + } + + public static String hmac(String canonical, 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.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)); + } +} diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessSigner.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessSigner.java new file mode 100644 index 000000000..cb6ab6300 --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessSigner.java @@ -0,0 +1,105 @@ +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 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 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 Map uploadHeaders(String target) { + FileAccessProperties.Client client = properties.getClients().get("service"); + if (client == null) throw new IllegalStateException("Missing file-access service signer configuration"); + String kid = client.getActiveKid(), key = properties.key("service", kid); + if (key == null || key.isBlank()) throw new IllegalStateException("Missing file-access service signer key"); + long ts = clock.instant().getEpochSecond(); + String nonce = UUID.randomUUID().toString().replace("-", ""); + Map headers = new LinkedHashMap<>(); + headers.put(FileUploadSignature.APP, "service"); + headers.put(FileUploadSignature.KID, kid); + headers.put(FileUploadSignature.TIMESTAMP, Long.toString(ts)); + headers.put(FileUploadSignature.NONCE, nonce); + headers.put(FileUploadSignature.SIGNATURE, FileUploadSignature.sign(target, "service", kid, ts, nonce, key)); + return headers; + } + + 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 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 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"); } +} diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileUploadSignature.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileUploadSignature.java new file mode 100644 index 000000000..cad09cd34 --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileUploadSignature.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.common.core.file; + +import java.util.Set; + +/** Upload authorization is purpose-separated from read signatures and is never put in URLs. */ +public final class FileUploadSignature { + public static final String APP = "X-File-App-Id"; + public static final String KID = "X-File-Key-Id"; + public static final String TIMESTAMP = "X-File-Timestamp"; + public static final String NONCE = "X-File-Nonce"; + public static final String SIGNATURE = "X-File-Signature"; + public static final Set TARGETS = Set.of("upload", "inner-upload", "qn-upload"); + + private FileUploadSignature() { } + + public static String canonical(String target, String appId, String kid, long timestamp, String nonce) { + if (!TARGETS.contains(target)) throw new IllegalArgumentException("Invalid file upload target"); + return String.join("\n", "v1", "oss-file-upload", "POST", target, appId, kid, Long.toString(timestamp), nonce); + } + + public static String sign(String target, String appId, String kid, long timestamp, String nonce, String key) { + return FileAccessSignature.hmac(canonical(target, appId, kid, timestamp, nonce), key); + } +} diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/util/AuditLogSanitizer.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/util/AuditLogSanitizer.java new file mode 100644 index 000000000..7e7ca3f7d --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/util/AuditLogSanitizer.java @@ -0,0 +1,318 @@ +package com.bsd.pjcloud.common.core.util; + +import com.bsd.pjcloud.common.core.jackson.PjcloudJavaTimeModule; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.StreamWriteConstraints; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import org.springframework.core.io.Resource; +import org.springframework.validation.BindingResult; +import org.springframework.web.multipart.MultipartFile; + +import java.io.*; +import java.lang.reflect.Array; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.*; +import java.util.regex.Pattern; + +/** 仅用于审计日志的副本过滤,不修改业务对象、业务 ObjectMapper 或系统配置。 */ +public final class AuditLogSanitizer { + public static final String HIDDEN = "参数已隐藏"; + private static final int MAX_DEPTH = 32; + private static final int MAX_NODES = 10000; + private static final ObjectMapper MAPPER = new ObjectMapper(JsonFactory.builder() + .streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(MAX_DEPTH).build()) + .streamWriteConstraints(StreamWriteConstraints.builder().maxNestingDepth(MAX_DEPTH).build()).build()) + .registerModule(new PjcloudJavaTimeModule()).enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + private static final Set CREDENTIALS = Set.of( + "pass", "password", "passwd", "pwd", "credential", "credentials", "salt", + "captcha", "captchacode", "captchaverification", "captchaid", "verifycode", "verificationcode", + "validatecode", "smscode", "imagecode", "otp", "otpcode", "randomstr", "authcode", + "token", "tokenid", "authtoken", "accesstoken", "refreshtoken", "idtoken", "resettoken", "authorization", "authorizationcode", + "clientsecret", "appsecret", "accesssecret", "accesskeysecret", "secret", "secretkey", + "privatekey", "apikey", "accesskey", "sign", "signature", "cookie", "setcookie", "sessionid", "jsessionid", + "密码", "口令", "验证码", "短信验证码", "图形验证码", "令牌", "访问令牌", "刷新令牌", "密钥", "私钥"); + private static final Set AUTH_CONTEXT_FIELDS = Set.of("randomstr", "captcha", "captchaid", "captchaverification", + "captchacode", "verifycode", "verificationcode", "validatecode", "smscode", "otp", "otpcode", "granttype"); + private static final Pattern PASSWORD = Pattern.compile( + "(?:old|new|current|confirm|confirmation|repeat|re|login|pay|trade|transaction|user|admin|initial|original|temporary|encoded|encrypted|hashed)*(?:password|passwd|pwd|pass)(?:s|confirm|confirmation|repeat|again|hash|digest)?\\d*"); + private static final Pattern CREDENTIAL_FAMILY = Pattern.compile( + "[\\p{L}\\p{N}]*(?:(?:password|passwd|pwd)(?:s|confirm|confirmation|repeat|again|hash|digest)?" + + "|secret|secretkey|privatekey|token|tokenid|tokenkey|apikey|accesskey|(?:captcha|verify|verification|validate|sms|otp|auth)code)\\d*"); + private static final Pattern AUTH_PATH = Pattern.compile( + "(?i)(?:^|/)(?:oauth2?|login|applogin|captcha|verifycode|smscode)(?:/|$)" + + "|/register/(?:password|default-user)(?:/|$)|/user/(?:personal/password|fix-phone-as-password|check)(?:/|$)" + + "|/code/(?:image|check|verify)(?:/|$)"); + private static final Pattern LABEL = Pattern.compile("(? excluded; + + public AuditLogSanitizer(Collection additionalFields) { + Set fields = new HashSet<>(CREDENTIALS); + if (additionalFields != null) additionalFields.stream().filter(Objects::nonNull).map(AuditLogSanitizer::normalize) + .forEach(fields::add); + excluded = Set.copyOf(fields); + } + + public boolean isAuthenticationContext(String uri) { + return uri != null && AUTH_PATH.matcher(uri.split("[?#]", 2)[0]).find(); + } + + public boolean isSensitiveField(String name, boolean authentication) { + if (name == null) return false; + if (name.length() > 256) throw new IllegalArgumentException("Unsupported audit field length"); + String decoded = decode(name); + for (String part : decoded.split("[.\\[\\]/]+")) { + String key = normalize(part); + if (excluded.contains(key) || PASSWORD.matcher(key).matches() || CREDENTIAL_FAMILY.matcher(key).matches() + || (authentication && "code".equals(key))) return true; + } + return false; + } + + /** 历史 params 只接受可识别的 JSON 或表单,匿名值与损坏报文不回退为原文。 */ + public String sanitizeParameters(String value, String uri) { + if (value == null || value.isBlank() || HIDDEN.equals(value)) return value; + try { + String input = structuredInput(value); + if (jsonLike(input)) return MAPPER.writeValueAsString(clean(MAPPER.readTree(input), isAuthenticationContext(uri), false, 0, new Budget())); + if (formLike(input)) return form(input, isAuthenticationContext(uri), 0, new Budget()); + } catch (Exception ignored) { + // 异常原文可能包含请求值,不能输出异常消息或回退到原始内容。 + } + return HIDDEN; + } + + /** 方法参数和 DTO 立即转成过滤后的字符串快照,异步事件不携带原始业务对象。 */ + public String sanitizePayload(Object value, String uri) { + if (value instanceof CharSequence sequence) return sanitizeParameters(sequence.toString(), uri); + try { + return MAPPER.writeValueAsString(clean(value, isAuthenticationContext(uri), false, 0, new Budget())); + } catch (Exception ignored) { + return HIDDEN; + } + } + + /** 保留 URL 表单及重复参数的语义,操作副本而非 servlet 参数映射。 */ + public String sanitizeRequestParameters(Map parameters, String uri) { + try { + List pairs = new ArrayList<>(); + for (var entry : parameters.entrySet()) { + if (entry.getValue() == null) continue; + for (String value : entry.getValue()) pairs.add(encode(entry.getKey()) + "=" + encode(value == null ? "" : value)); + } + return form(String.join("&", pairs), isAuthenticationContext(uri), 0, new Budget()); + } catch (Exception ignored) { + return HIDDEN; + } + } + + /** 普通错误文本保留;含可识别凭据或损坏结构化内容时隐藏,不尝试猜测秘密值边界。 */ + public String sanitizeText(String value, String uri) { + if (value == null || value.isBlank() || HIDDEN.equals(value)) return value; + try { + String input = structuredInput(value); + if (jsonLike(input) || formLike(input)) return sanitizeParameters(input, uri); + if (unsafeText(input, isAuthenticationContext(uri))) return HIDDEN; + return value; + } catch (Exception ignored) { + return HIDDEN; + } + } + + public String sanitizeUri(String uri) { + return sanitizeUri(uri, Map.of()); + } + + /** 新请求利用路径参数名称过滤;历史记录覆盖已知的认证路径。 */ + public String sanitizeUri(String uri, Map pathVariables) { + if (uri == null || uri.isBlank() || HIDDEN.equals(uri)) return uri; + try { + int hash = uri.indexOf('#'); + String fragment = hash < 0 ? "" : uri.substring(hash + 1); + String main = hash < 0 ? uri : uri.substring(0, hash); + int question = main.indexOf('?'); + String path = question < 0 ? main : main.substring(0, question); + boolean authentication = isAuthenticationContext(path); + String[] segments = path.split("/", -1); + for (int index = 0; index < segments.length; index++) { + String segment = decode(segments[index]); + for (var variable : pathVariables.entrySet()) { + if (isSensitiveField(variable.getKey(), authentication) && Objects.equals(segment, variable.getValue())) segments[index] = HIDDEN; + } + } + path = String.join("/", segments).replaceAll("(?i)(/appLogin/(?:zzdsso|getAuthToken)/)[^/?#]+", "$1" + HIDDEN) + .replaceAll("(?i)(https?://)[^/@\\s]+@", "$1" + HIDDEN + "@"); + if (unsafeText(path, authentication)) return HIDDEN; + if (question >= 0) { + String query = safeUriForm(main.substring(question + 1), authentication); + if (!query.isBlank()) path += "?" + query; + } + if (!fragment.isBlank()) { + String safeFragment = formLike(fragment) ? safeUriForm(fragment, authentication) + : authentication || unsafeText(fragment, false) ? HIDDEN : fragment; + path += "#" + safeFragment; + } + return path; + } catch (Exception ignored) { + return HIDDEN; + } + } + + private JsonNode clean(Object value, boolean authentication, boolean named, int depth, Budget budget) throws Exception { + budget.visit(depth); + if (value == null || value instanceof JsonNode node && node.isNull()) return MAPPER.nullNode(); + if (isUnloggable(value)) throw new UnsafePayload(); + if (value instanceof Map map) { + ObjectNode result = MAPPER.createObjectNode(); + boolean auth = authentication || authenticationMap(map); + for (var entry : map.entrySet()) { + if (!(entry.getKey() instanceof String key)) throw new UnsafePayload(); + if (!isSensitiveField(key, auth) && !isUnloggable(entry.getValue())) result.set(key, clean(entry.getValue(), auth, true, depth + 1, budget)); + } + return result; + } + if (value instanceof JsonNode node && node.isObject()) { + Map entries = new LinkedHashMap<>(); + node.fields().forEachRemaining(entry -> entries.put(entry.getKey(), entry.getValue())); + return clean(entries, authentication, named, depth + 1, budget); + } + if (value instanceof JsonNode node && node.isArray() + || !(value instanceof JsonNode) && value instanceof Iterable || value.getClass().isArray()) { + ArrayNode result = MAPPER.createArrayNode(); + Iterable items = value instanceof Iterable iterable ? iterable : arrayItems(value); + for (Object item : items) if (!isUnloggable(item)) result.add(clean(item, authentication, named, depth + 1, budget)); + return result; + } + if (value instanceof CharSequence || value instanceof JsonNode node && node.isTextual()) { + String text = value instanceof JsonNode node ? node.textValue() : value.toString(); + if (!named) throw new UnsafePayload(); + String input = structuredInput(text); + if (jsonLike(input)) return MAPPER.getNodeFactory().textNode(MAPPER.writeValueAsString(clean(MAPPER.readTree(input), authentication, false, depth + 1, budget))); + if (formLike(input)) return MAPPER.getNodeFactory().textNode(form(input, authentication, depth + 1, budget)); + return MAPPER.getNodeFactory().textNode(unsafeText(input, authentication) ? HIDDEN : text); + } + if (value instanceof JsonNode node && node.isValueNode() || value instanceof Number || value instanceof Boolean || value instanceof Character || value instanceof Enum) { + if (!named) throw new UnsafePayload(); + return value instanceof JsonNode node ? node.deepCopy() : MAPPER.valueToTree(value); + } + return clean(MAPPER.valueToTree(value), authentication, named, depth + 1, budget); + } + + public static boolean isUnloggable(Object value) { + return value instanceof ServletRequest || value instanceof ServletResponse || value instanceof BindingResult + || value instanceof MultipartFile || value instanceof Resource || value instanceof InputStream + || value instanceof OutputStream || value instanceof Reader || value instanceof Writer + || value instanceof File || value instanceof Path; + } + + private boolean authenticationMap(Map map) { + for (var entry : map.entrySet()) { + if (!(entry.getKey() instanceof String field)) continue; + String key = normalize(decode(field)); + if (AUTH_CONTEXT_FIELDS.contains(key)) return true; + Object value = entry.getValue(); + String text = value instanceof JsonNode node && node.isTextual() ? node.textValue() + : value instanceof CharSequence sequence ? sequence.toString() + : value instanceof Enum enumeration ? enumeration.name() : null; + if ("bizcode".equals(key) && "SMS_NORMAL_CODE".equalsIgnoreCase(text)) return true; + } + return false; + } + + private String form(String value, boolean authentication, int depth, Budget budget) throws Exception { + budget.visit(depth); + if (value.isBlank()) return ""; + List pairs = new ArrayList<>(); + Map context = new LinkedHashMap<>(); + for (String part : value.split("&", -1)) { + if (part.isBlank()) continue; + int equals = part.indexOf('='); + String key = decode(equals < 0 ? part : part.substring(0, equals)); + if (key.isBlank() || !FORM_KEY.matcher(key).matches()) throw new UnsafePayload(); + String text = equals < 0 ? "" : decode(part.substring(equals + 1)); + pairs.add(new String[]{key, text}); + context.put(key, text); + } + boolean auth = authentication || authenticationMap(context); + List safe = new ArrayList<>(); + for (String[] pair : pairs) if (!isSensitiveField(pair[0], auth)) { + JsonNode cleaned = clean(pair[1], auth, true, depth + 1, budget); + safe.add(encode(pair[0]) + "=" + encode(cleaned.textValue())); + } + return String.join("&", safe); + } + + private boolean unsafeText(String value, boolean authentication) { + if (AUTH_VALUE.matcher(value).find()) return true; + var matcher = LABEL.matcher(value); + while (matcher.find()) if (isSensitiveField(matcher.group(1), authentication)) return true; + return false; + } + + private String safeUriForm(String value, boolean authentication) { + try { + return form(value, authentication, 0, new Budget()); + } catch (Exception ignored) { + return HIDDEN; + } + } + + private static String structuredInput(String value) { + String input = value.trim(); + if (!jsonLike(input) && !formLike(input) && input.matches("(?s).*%[0-9a-fA-F]{2}.*")) input = decode(input); + return input; + } + + private static boolean jsonLike(String value) { + return value.startsWith("{") || value.startsWith("[") || value.startsWith("\""); + } + + private static boolean formLike(String value) { + int equals = value.indexOf('='); + return equals > 0 && FORM_KEY.matcher(value.substring(0, equals)).matches(); + } + + private static String normalize(String value) { + return value.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]", ""); + } + + private static String decode(String value) { + String decoded = value; + for (int attempt = 0; attempt < 3; attempt++) { + String next = URLDecoder.decode(decoded, StandardCharsets.UTF_8); + if (next.equals(decoded) || !next.matches("(?s).*%[0-9a-fA-F]{2}.*")) return next; + decoded = next; + } + throw new IllegalArgumentException("Unsupported parameter encoding"); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static Iterable arrayItems(Object array) { + List items = new ArrayList<>(); + for (int index = 0; index < Array.getLength(array); index++) items.add(Array.get(array, index)); + return items; + } + + private static final class Budget { + private int nodes; + private void visit(int depth) throws UnsafePayload { + if (depth > MAX_DEPTH || ++nodes > MAX_NODES) throw new UnsafePayload(); + } + } + + private static final class UnsafePayload extends Exception {} +} diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/pjcloud-common/pjcloud-common-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index e63a1fef9..97d3cac14 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/pjcloud-common/pjcloud-common-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -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 diff --git a/pjcloud-common/pjcloud-common-core/src/test/java/com/bsd/pjcloud/common/core/util/AuditLogSanitizerTest.java b/pjcloud-common/pjcloud-common-core/src/test/java/com/bsd/pjcloud/common/core/util/AuditLogSanitizerTest.java new file mode 100644 index 000000000..361498e76 --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/test/java/com/bsd/pjcloud/common/core/util/AuditLogSanitizerTest.java @@ -0,0 +1,176 @@ +package com.bsd.pjcloud.common.core.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.ByteArrayInputStream; +import java.time.LocalDateTime; +import java.time.Duration; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class AuditLogSanitizerTest { + private final AuditLogSanitizer sanitizer = new AuditLogSanitizer(List.of()); + private final ObjectMapper json = new ObjectMapper(); + + @ParameterizedTest + @ValueSource(strings = {"password", "old_password", "New-Password", "newpassword1", "newpassword2", + "ConfirmNewPassword", "password_confirm", "PWD", "passwd", "pass", "captchaVerification", "sms_code", + "验证码", "Authorization", "client_secret", "appSecret", "private-key", "access_token", "refresh_token", + "accessKeySecret", "COOKIE", "data.password", "users[0].password", "p%61ssword", "passwordHash", "encryptedPassword", + "rsaPrivateKey", "selfAppSecret", "selfTokenKey", "selfTokenSecret", "smtpPassword", "X-Auth-Token"}) + void dropsCredentialKeysButPreservesBusinessValues(String field) throws Exception { + var original = new LinkedHashMap(); + original.put(field, "credential-value"); + original.put("username", "operator"); + original.put("roleCode", "ADMIN"); + JsonNode result = json.readTree(sanitizer.sanitizePayload(original, "/admin/user")); + assertEquals(json.readTree("{\"username\":\"operator\",\"roleCode\":\"ADMIN\"}"), result); + assertEquals("credential-value", original.get(field)); + } + + @Test + void recursivelyFiltersMapsBeansArraysAndEmbeddedJsonWithoutChangingInputs() throws Exception { + Map child = new LinkedHashMap<>(Map.of("password", "child-secret", "id", 7)); + Map original = new LinkedHashMap<>(); + original.put("items", List.of(child)); + original.put("ids", new long[]{1, 2}); + original.put("payload", "{\"newpassword1\":\"embedded-secret\",\"code\":\"BUS-42\",\"rows\":[{\"token\":\"embedded-token\",\"id\":9}]}"); + original.put("bean", new LoginBean()); + original.put("created", LocalDateTime.of(2026, 10, 29, 12, 30)); + original.put("stream", new ByteArrayInputStream(new byte[]{1, 2})); + + String output = sanitizer.sanitizePayload(original, "/orders/update"); + JsonNode result = json.readTree(output); + assertEquals(7, result.at("/items/0/id").intValue()); + assertFalse(result.at("/items/0").has("password")); + assertEquals(json.readTree("[1,2]"), result.get("ids")); + assertEquals("2026-10-29 12:30:00", result.get("created").textValue()); + assertEquals("BUS-42", json.readTree(result.get("payload").textValue()).get("code").textValue()); + assertEquals("operator", result.at("/bean/username").textValue()); + assertFalse(result.has("stream")); + for (String secret : List.of("child-secret", "embedded-secret", "embedded-token", "bean-secret")) assertFalse(output.contains(secret)); + assertEquals("child-secret", child.get("password")); + assertTrue(original.get("payload").toString().contains("embedded-secret")); + } + + @Test + void formFilteringHandlesEncodedKeysRepeatedValuesAndNestedPayloads() { + String original = "username=operator&password=one&code=BUS-42&roleCode=ADMIN&id=1&id=2&p%61ssword=two" + + "&payload=%7B%22new_password%22%3A%22three%22%2C%22id%22%3A9%7D"; + assertEquals("username=operator&code=BUS-42&roleCode=ADMIN&id=1&id=2&payload=%7B%22id%22%3A9%7D", + sanitizer.sanitizeParameters(original, "/orders/update")); + assertTrue(original.contains("password=one")); + String[] ids = {"1", "2"}; + Map request = new LinkedHashMap<>(); + request.put("password", new String[]{"raw-secret"}); + request.put("ids", ids); + request.put("note", new String[]{"a+b 50% 中文"}); + assertEquals("ids=1&ids=2¬e=a%2Bb+50%25+%E4%B8%AD%E6%96%87", sanitizer.sanitizeRequestParameters(request, "/orders")); + assertSame(ids, request.get("ids")); + assertEquals("raw-secret", request.get("password")[0]); + } + + @Test + void genericCodeIsRemovedOnlyInAuthenticationAndVerificationContexts() throws Exception { + String parameters = "code=123456&bizCode=BUS-42&roleCode=ADMIN&passwordExpireFlag=0"; + assertEquals(parameters, sanitizer.sanitizeParameters(parameters, "/role/update")); + for (String uri : List.of("/oauth2/token", "/auth/oauth2/token", "/api/admin/oauth2/token", "/login", "/register/password")) { + assertEquals("bizCode=BUS-42&roleCode=ADMIN&passwordExpireFlag=0", sanitizer.sanitizeParameters(parameters, uri)); + } + JsonNode sms = json.readTree(sanitizer.sanitizeParameters( + "{\"bizCode\":\"SMS_NORMAL_CODE\",\"params\":{\"code\":\"123456\",\"orderId\":42}}", "/message/send/sms")); + assertFalse(sms.get("params").has("code")); + assertEquals(42, sms.at("/params/orderId").intValue()); + assertEquals("SMS_NORMAL_CODE", sms.get("bizCode").textValue()); + assertEquals("username=operator", sanitizer.sanitizeParameters("code=123456&randomStr=proof&username=operator", "/custom")); + } + + @ParameterizedTest + @ValueSource(strings = {"opaque-secret", "\"opaque-secret\"", "[\"opaque-secret\"]", "[1,2]", + "{\"password\":\"truncated-secret", "{\"id\":1} {\"password\":\"tail-secret\"}", + "password=%ZZ", "p%ZZassword=broken", "Bearer opaque-token"}) + void damagedOrAnonymousParametersFailClosed(String original) { + assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeParameters(original, "/orders")); + } + + @Test + void emptyValuesAreStableAndSanitizationIsIdempotent() { + assertNull(sanitizer.sanitizeParameters(null, "/orders")); + assertEquals("", sanitizer.sanitizeParameters("", "/orders")); + String result = sanitizer.sanitizeParameters("[{\"password\":\"secret\",\"id\":1},null]", "/orders"); + assertEquals("[{\"id\":1},null]", result); + assertEquals(result, sanitizer.sanitizeParameters(result, "/orders")); + assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeParameters(AuditLogSanitizer.HIDDEN, "/orders")); + } + + @Test + void urlsAndExceptionsDoNotEchoRecognizableCredentials() { + assertEquals("https://example.test/auth/oauth2/token?username=operator&grant_type=password", + sanitizer.sanitizeUri("https://example.test/auth/oauth2/token?username=operator&code=123456&password=secret&grant_type=password")); + assertEquals("/orders?code=BUS-42", sanitizer.sanitizeUri("/orders?code=BUS-42&client_secret=secret")); + assertEquals("/orders?" + AuditLogSanitizer.HIDDEN, sanitizer.sanitizeUri("/orders?password=%ZZ")); + assertEquals("/custom/" + AuditLogSanitizer.HIDDEN, sanitizer.sanitizeUri("/custom/path-secret", Map.of("token", "path-secret"))); + assertEquals("/appLogin/getAuthToken/" + AuditLogSanitizer.HIDDEN, sanitizer.sanitizeUri("/appLogin/getAuthToken/path-secret")); + assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeText("failed password=plain-secret", "/orders")); + assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeText("upstream returned Bearer abc123", "/orders")); + assertEquals("java.lang.IllegalStateException: 用户不存在\n at example.User.update(User.java:42)", + sanitizer.sanitizeText("java.lang.IllegalStateException: 用户不存在\n at example.User.update(User.java:42)", "/orders")); + } + + @Test + void malformedObjectsCyclesAndSerializationFailuresDoNotEscapeOrFallBackToRawValues() { + Map cycle = new HashMap<>(); + cycle.put("next", cycle); + assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(cycle, "/orders")); + Map indirect = new HashMap<>(); + indirect.put("parent", cycle); + cycle.put("bizCode", indirect); + assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(cycle, "/orders")); + assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(new ExplodingBean(), "/orders")); + assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeParameters("{\"nested\":\"{broken-password=secret\"}", "/orders")); + } + + @Test + void doubleEncodedNestedJsonIsDecodedAndFilteredWithoutChangingBusinessJsonStrings() throws Exception { + String input = "{\"payload\":\"%257B%2522password%2522%253A%2522encoded-secret%2522%252C%2522id%2522%253A42%257D\",\"code\":\"BUS-42\"}"; + JsonNode result = json.readTree(sanitizer.sanitizeParameters(input, "/orders")); + assertEquals(json.readTree("{\"id\":42}"), json.readTree(result.get("payload").textValue())); + assertEquals("BUS-42", result.get("code").textValue()); + JsonNode form = json.readTree(sanitizer.sanitizeParameters("{\"payload\":\"password%253Dform-secret%2526id%253D42\"}", "/orders")); + assertEquals("id=42", form.get("payload").textValue()); + assertEquals("/orders?note=a%26id%3D3", sanitizer.sanitizeUri("/orders?note=a%26id%3D3&token=secret")); + assertEquals("", sanitizer.sanitizeText("password%3Dencoded-error-secret", "/orders")); + } + + @Test + void longBusinessValuesArePreservedWithoutUnboundedCredentialLabelMatching() { + String description = "a".repeat(100000); + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { + String result = sanitizer.sanitizePayload(Map.of("description", description), "/orders"); + assertTrue(result.contains(description)); + assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(Map.of("new".repeat(200) + "password", "secret"), "/orders")); + }); + } + + @Test + void configuredAdditionalFieldsCannotDisableMandatoryCredentialFiltering() throws Exception { + var customized = new AuditLogSanitizer(List.of("phone", "custom-field")); + JsonNode result = json.readTree(customized.sanitizeParameters( + "{\"phone\":\"123\",\"customField\":\"private\",\"PASSWORD\":\"secret\",\"passwordExpireFlag\":0,\"publicKey\":\"public\",\"clientId\":\"web\"}", "/orders")); + assertEquals(json.readTree("{\"passwordExpireFlag\":0,\"publicKey\":\"public\",\"clientId\":\"web\"}"), result); + } + + public static class LoginBean { + public String getUsername() { return "operator"; } + public String getNewpassword1() { return "bean-secret"; } + } + + public static class ExplodingBean { + public String getValue() { throw new IllegalStateException("getter-error-containing-private-secret"); } + } +} diff --git a/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/tenant/TenantContextHolder.java b/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/tenant/TenantContextHolder.java index 380c82176..01c59dcdf 100644 --- a/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/tenant/TenantContextHolder.java +++ b/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/tenant/TenantContextHolder.java @@ -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 diff --git a/pjcloud-common/pjcloud-common-excel/README.md b/pjcloud-common/pjcloud-common-excel/README.md index d05b7cd73..ec74c1489 100644 --- a/pjcloud-common/pjcloud-common-excel/README.md +++ b/pjcloud-common/pjcloud-common-excel/README.md @@ -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,16 @@ 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,缓存工作簿中的字体与单元格样式,条纹按实际行号计算,适用于分批流式写入。现有转换器、单元格类型、数据格式及导出列保持原样。固定格式模板应继续使用自身样式,不自动注册此处理器。 + +日志导出使用独立的 `SysLogExcelVO`,保留原有信息并按创建时间、类型、标题、操作人、应用、请求方式、请求地址、耗时、IP、提交数据、异常、用户代理、编号的顺序展示。类型显示“正常/错误”,请求地址和用户代理使用明确的中文表头;创建时间保存为可排序的日期单元格,耗时为数字,编号为保留完整精度的文本。报告顶部展示创建时间范围,表头固定为一行,长文本自动换行并适度增加行高,原文保留供展开查看。横向滚动时冻结时间和类型,纵向滚动时冻结报告上下文及表头。 + +日志导出必须选择完整的创建时间范围,结束时间不得早于开始时间,也不得超过开始时间加一个自然月;前端和同步、异步服务端接口均校验,未通过时不查询日志或创建任务。自然月按月底截断规则计算,例如 1 月 31 日 08:30 到 2 月 28 日 08:30 允许,超过一秒即拒绝。列表查询本身不受导出时间限制。 + +每批按编号倒序读取 200 条,仅首批统计总数,后续使用编号游标;新增的更大编号不会挤偏后续批次。旧 GET `/admin/log/export` 仍直接返回附件,新 POST `/admin/log/export-async` 返回任务信息,使用 `/admin/async-task/progress` 和命名空间 `admin:export:log` 查询进度。两个接口均要求 `sys_log_export` 权限,超出单工作表行数限制时提示缩小筛选范围。 diff --git a/pjcloud-common/pjcloud-common-log/README.md b/pjcloud-common/pjcloud-common-log/README.md new file mode 100644 index 000000000..5c523e684 --- /dev/null +++ b/pjcloud-common/pjcloud-common-log/README.md @@ -0,0 +1,14 @@ +# SysLog 认证参数过滤 + +`AuditLogSanitizer` 是独立的日志过滤器,不修改业务请求、全局 ObjectMapper 或系统配置使用的 `JacksonSensitiveFieldUtil`。 + +- 密码、新旧及确认密码、验证码、OTP、`randomStr`、令牌、Authorization、客户端密钥、应用密钥、私钥等字段从结构化日志中删除。字段名匹配忽略大小写、下划线及连字符,支持嵌套字段、JSON 字符串和 URL 编码。 +- 通用 `code` 只在登录认证请求、带验证码上下文的参数或 `SMS_NORMAL_CODE` 短信模板中去除。普通业务 `code`、`bizCode`、`roleCode` 和密码过期标记等字段保留。 +- `pjcloud.log.exclude-fields` 继续作为附加排除规则。即使配置为空,也不会关闭认证凭据的必需过滤。原有日志开关和请求体采集开关继续生效。 +- 新请求采集方法参数名并立即生成过滤后的副本,跳过 Cookie 参数、请求、响应、文件、流和 BindingResult;异步事件不携带原始请求体。监听器兼容旧事件,并在远程写入前再次过滤。 +- 解析失败、截断、匿名参数值、循环引用、序列化失败、超过结构遍历限制或过滤后仍超过 `max-length` 的报文显示“参数已隐藏”,不回退为原文,也不打印可能带有秘密的解析异常消息。JSON 遍历深度上限为 32,节点上限为 10000。 +- URL 查询参数和已知认证路径、异常文本中的可识别凭据也会过滤。正常错误说明和堆栈保留;无法安全判断边界时隐藏对应内容。 + +UPMS 的单条写入、前端错误日志批量写入、分页查询以及同步和异步 Excel 导出共用这组规则。查询与导出操作副本,不更新历史数据库;已有导出的文件不会被改写。数据库结构、API 结构及导出布局不变。 + +发布时先更新 UPMS,保护入库与历史日志读取,再更新认证及依赖 common-log 的日志生产服务。本实现只针对 SysLog,不修改其他日志表或应用运行日志。 diff --git a/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/LogAutoConfiguration.java b/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/LogAutoConfiguration.java index 21eb95f02..c349096f3 100644 --- a/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/LogAutoConfiguration.java +++ b/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/LogAutoConfiguration.java @@ -51,8 +51,8 @@ public class LogAutoConfiguration { } @Bean - public SysLogAspect sysLogAspect(KeyStrResolver resolver) { - return new SysLogAspect(resolver); + public SysLogAspect sysLogAspect(KeyStrResolver resolver, PjcloudLogProperties properties) { + return new SysLogAspect(resolver, properties); } } diff --git a/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/aspect/SysLogAspect.java b/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/aspect/SysLogAspect.java index 2e47038da..9b20da1f4 100644 --- a/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/aspect/SysLogAspect.java +++ b/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/aspect/SysLogAspect.java @@ -20,7 +20,9 @@ import cn.hutool.core.util.StrUtil; import com.bsd.pjcloud.admin.api.dto.SysLogDTO; import com.bsd.pjcloud.common.core.util.KeyStrResolver; import com.bsd.pjcloud.common.core.util.SpringContextHolder; +import com.bsd.pjcloud.common.core.util.AuditLogSanitizer; import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.common.log.config.PjcloudLogProperties; import com.bsd.pjcloud.common.log.event.SysLogEvent; import com.bsd.pjcloud.common.log.util.LogTypeEnum; import com.bsd.pjcloud.common.log.util.SysLogUtils; @@ -44,6 +46,7 @@ import org.springframework.expression.EvaluationContext; public class SysLogAspect { private final KeyStrResolver tenantKeyStrResolver; + private final PjcloudLogProperties logProperties; @Around("@annotation(sysLog)") @SneakyThrows @@ -71,8 +74,9 @@ public class SysLogAspect { SysLogDTO logVo = SysLogUtils.getSysLog(); logVo.setTitle(value); // 获取请求body参数 - if (StrUtil.isBlank(logVo.getParams())) { - logVo.setBody(point.getArgs()); + if (logProperties.isRequestEnabled() && StrUtil.isBlank(logVo.getParams())) { + logVo.setParams(SysLogUtils.captureParameters(((MethodSignature) point.getSignature()).getMethod(), + point.getArgs(), logVo.getRequestUri(), logProperties)); } // 发送异步日志事件 Long startTime = System.currentTimeMillis(); @@ -90,6 +94,10 @@ public class SysLogAspect { Long endTime = System.currentTimeMillis(); logVo.setTime(endTime - startTime); logVo.setTenantId(Long.parseLong(tenantKeyStrResolver.key())); + AuditLogSanitizer sanitizer = new AuditLogSanitizer(logProperties.getExcludeFields()); + logVo.setTitle(sanitizer.sanitizeText(logVo.getTitle(), logVo.getRequestUri())); + logVo.setException(sanitizer.sanitizeText(logVo.getException(), logVo.getRequestUri())); + logVo.setBody(null); SpringContextHolder.publishEvent(new SysLogEvent(logVo)); } diff --git a/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/config/PjcloudLogProperties.java b/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/config/PjcloudLogProperties.java index d8d99c9c4..413c99315 100644 --- a/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/config/PjcloudLogProperties.java +++ b/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/config/PjcloudLogProperties.java @@ -34,6 +34,7 @@ import java.util.List; public class PjcloudLogProperties { public static final String PREFIX = "pjcloud.log"; + public static final String DEFAULT_EXCLUDE_FIELDS = "password,mobile,idcard,phone,accessSecret,tokenId,sign"; /** * 开启日志记录 @@ -46,10 +47,10 @@ public class PjcloudLogProperties { private boolean requestEnabled = true; /** - * 放行字段,password,mobile,idcard,phone + * 日志附加排除字段。认证凭据的必需过滤规则由 AuditLogSanitizer 独立执行。 */ - @Value("${pjcloud.log.exclude-fields:password,mobile,idcard,phone,accessSecret,tokenId,sign}") - private List excludeFields; + @Value("${pjcloud.log.exclude-fields:" + DEFAULT_EXCLUDE_FIELDS + "}") + private List excludeFields = List.of(DEFAULT_EXCLUDE_FIELDS.split(",")); /** * 请求报文最大存储长度 diff --git a/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/event/SysLogListener.java b/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/event/SysLogListener.java index 7f04227db..027b2cee1 100644 --- a/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/event/SysLogListener.java +++ b/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/event/SysLogListener.java @@ -16,77 +16,54 @@ package com.bsd.pjcloud.common.log.event; -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.util.StrUtil; import com.bsd.pjcloud.admin.api.dto.SysLogDTO; import com.bsd.pjcloud.admin.api.feign.RemoteLogService; import com.bsd.pjcloud.common.core.jackson.PjcloudJavaTimeModule; +import com.bsd.pjcloud.common.core.util.AuditLogSanitizer; import com.bsd.pjcloud.common.log.config.PjcloudLogProperties; import com.bsd.pjcloud.common.log.util.JacksonSensitiveFieldUtil; -import jakarta.servlet.ServletRequest; import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.event.EventListener; import org.springframework.core.annotation.Order; import org.springframework.scheduling.annotation.Async; -import org.springframework.validation.BindingResult; - -import java.util.Arrays; -import java.util.List; -import java.util.Objects; /** * @author pjcloud 异步监听日志事件 */ -@Slf4j @RequiredArgsConstructor public class SysLogListener implements InitializingBean { - /** - * 忽略序列化的对象类型 - */ - private final static Class[] ignoreClass = {ServletRequest.class, BindingResult.class}; - - /** - * new 一个 避免日志脱敏策略影响全局ObjectMapper - */ - private final RemoteLogService remoteLogService; private final PjcloudLogProperties logProperties; - @SneakyThrows @Async @Order @EventListener(SysLogEvent.class) public void saveSysLog(SysLogEvent event) { SysLogDTO source = (SysLogDTO) event.getSource(); - // json 格式刷参数放在异步中处理,提升性能 - if (Objects.nonNull(source.getBody()) && logProperties.isRequestEnabled()) { - Object[] args = (Object[]) source.getBody(); - List list = CollUtil.toList(args); - // 删除部分无法序列化的参数 - list.removeIf(obj -> Arrays.stream(ignoreClass).anyMatch(clazz -> clazz.isAssignableFrom(obj.getClass()))); - - try { - // 序列化参数 - String params = JacksonSensitiveFieldUtil.getObjectMapper().writeValueAsString(list); - source.setParams(StrUtil.subPre(params, logProperties.getMaxLength())); - } catch (Exception e) { - log.error("请求参数序列化异常:{}", e.getMessage()); - } - } - + AuditLogSanitizer sanitizer = new AuditLogSanitizer(logProperties.getExcludeFields()); + String uri = source.getRequestUri(); + String params = source.getBody() != null && logProperties.isRequestEnabled() + ? sanitizer.sanitizePayload(source.getBody(), uri) : sanitizer.sanitizeParameters(source.getParams(), uri); + int maximum = logProperties.getMaxLength() == null ? 2000 : Math.max(0, logProperties.getMaxLength()); + // 截断后仍保持 fail-closed,不能把损坏报文作为未过滤的原文返回。 + if (params != null && params.length() > maximum) params = AuditLogSanitizer.HIDDEN; + source.setParams(params); + source.setRequestUri(sanitizer.sanitizeUri(uri)); + source.setException(sanitizer.sanitizeText(source.getException(), uri)); + source.setTitle(sanitizer.sanitizeText(source.getTitle(), uri)); + source.setUserAgent(sanitizer.sanitizeText(source.getUserAgent(), uri)); source.setBody(null); remoteLogService.saveLog(source); } @Override public void afterPropertiesSet() { - String[] ignorableFieldNames = logProperties.getExcludeFields().toArray(new String[0]); + String[] ignorableFieldNames = logProperties.getExcludeFields() == null ? new String[0] + : logProperties.getExcludeFields().toArray(new String[0]); JacksonSensitiveFieldUtil.configureSensitiveFields(ignorableFieldNames); JacksonSensitiveFieldUtil.registerCustomModule(new PjcloudJavaTimeModule()); } diff --git a/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/util/SysLogUtils.java b/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/util/SysLogUtils.java index ff4cc3471..db6a8d0c9 100644 --- a/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/util/SysLogUtils.java +++ b/pjcloud-common/pjcloud-common-log/src/main/java/com/bsd/pjcloud/common/log/util/SysLogUtils.java @@ -17,13 +17,12 @@ package com.bsd.pjcloud.common.log.util; import cn.hutool.core.map.MapUtil; -import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.URLUtil; import cn.hutool.extra.servlet.JakartaServletUtil; import cn.hutool.extra.spring.SpringUtil; -import cn.hutool.http.HttpUtil; import com.bsd.pjcloud.admin.api.dto.SysLogDTO; import com.bsd.pjcloud.common.core.constant.SecurityConstants; +import com.bsd.pjcloud.common.core.util.AuditLogSanitizer; import com.bsd.pjcloud.common.log.config.PjcloudLogProperties; import jakarta.servlet.http.HttpServletRequest; import lombok.experimental.UtilityClass; @@ -38,9 +37,16 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.servlet.HandlerMapping; import java.lang.reflect.Method; -import java.util.HashMap; +import java.lang.reflect.Parameter; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -57,7 +63,7 @@ public class SysLogUtils { .requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest(); SysLogDTO sysLog = new SysLogDTO(); sysLog.setLogType(LogTypeEnum.NORMAL.getType()); - sysLog.setRequestUri(URLUtil.getPath(request.getRequestURI())); + String uri = URLUtil.getPath(request.getRequestURI()); sysLog.setMethod(request.getMethod()); sysLog.setRemoteAddr(JakartaServletUtil.getClientIP(request)); sysLog.setUserAgent(request.getHeader(HttpHeaders.USER_AGENT)); @@ -65,16 +71,63 @@ public class SysLogUtils { // 获取服务名称 sysLog.setServiceId(SpringUtil.getProperty("spring.application.name")); - // get 参数脱敏 PjcloudLogProperties logProperties = SpringUtil.getBean(PjcloudLogProperties.class); - - // 修复request.getParameterMap()其他容器不允许改,tomcat会报错,当然undertow默认可以的 - Map paramsMap = MapUtil.removeAny(new HashMap<>(request.getParameterMap()), - ArrayUtil.toArray(logProperties.getExcludeFields(), String.class)); - sysLog.setParams(HttpUtil.toParams(paramsMap)); + AuditLogSanitizer sanitizer = new AuditLogSanitizer(logProperties.getExcludeFields()); + sysLog.setUserAgent(sanitizer.sanitizeText(sysLog.getUserAgent(), uri)); + Object variables = request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + Map pathVariables = new LinkedHashMap<>(); + if (variables instanceof Map map) map.forEach((key, value) -> { + if (key instanceof String name && value instanceof String text) pathVariables.put(name, text); + }); + sysLog.setRequestUri(sanitizer.sanitizeUri(uri, pathVariables)); + sysLog.setParams(sanitizer.sanitizeRequestParameters(request.getParameterMap(), uri)); return sysLog; } + /** 将命名参数记录为副本;原始 DTO、数组、文件和请求对象不进入异步事件。 */ + public String captureParameters(Method method, Object[] arguments, String uri, PjcloudLogProperties properties) { + try { + return captureNamedParameters(method, arguments, uri, properties); + } catch (Exception ignored) { + return AuditLogSanitizer.HIDDEN; + } + } + + private String captureNamedParameters(Method method, Object[] arguments, String uri, PjcloudLogProperties properties) { + AuditLogSanitizer sanitizer = new AuditLogSanitizer(properties.getExcludeFields()); + Map named = new LinkedHashMap<>(); + Parameter[] parameters = method.getParameters(); + for (int index = 0; index < arguments.length; index++) { + Object argument = arguments[index]; + if (AuditLogSanitizer.isUnloggable(argument)) continue; + if (index >= parameters.length) return AuditLogSanitizer.HIDDEN; + Parameter parameter = parameters[index]; + // Cookie 参数可能承载不固定名称的会话凭据,不进入操作参数快照。 + if (parameter.isAnnotationPresent(CookieValue.class)) continue; + String name = parameterName(parameter); + if (name == null) return AuditLogSanitizer.HIDDEN; + if (sanitizer.isSensitiveField(name, sanitizer.isAuthenticationContext(uri))) continue; + if (argument instanceof CharSequence text && parameter.isAnnotationPresent(RequestBody.class)) { + argument = sanitizer.sanitizeParameters(text.toString(), uri); + } + named.put(name, argument); + } + return sanitizer.sanitizePayload(named, uri); + } + + private String parameterName(Parameter parameter) { + RequestParam query = parameter.getAnnotation(RequestParam.class); + if (query != null && !query.name().isBlank()) return query.name(); + if (query != null && !query.value().isBlank()) return query.value(); + PathVariable path = parameter.getAnnotation(PathVariable.class); + if (path != null && !path.name().isBlank()) return path.name(); + if (path != null && !path.value().isBlank()) return path.value(); + RequestHeader header = parameter.getAnnotation(RequestHeader.class); + if (header != null && !header.name().isBlank()) return header.name(); + if (header != null && !header.value().isBlank()) return header.value(); + return parameter.isNamePresent() ? parameter.getName() : null; + } + /** * 获取用户名称 * @return username diff --git a/pjcloud-common/pjcloud-common-log/src/test/java/com/bsd/pjcloud/common/log/SysLogSanitizationTest.java b/pjcloud-common/pjcloud-common-log/src/test/java/com/bsd/pjcloud/common/log/SysLogSanitizationTest.java new file mode 100644 index 000000000..0f82f4259 --- /dev/null +++ b/pjcloud-common/pjcloud-common-log/src/test/java/com/bsd/pjcloud/common/log/SysLogSanitizationTest.java @@ -0,0 +1,252 @@ +package com.bsd.pjcloud.common.log; + +import cn.hutool.extra.spring.SpringUtil; +import com.bsd.pjcloud.admin.api.dto.SysLogDTO; +import com.bsd.pjcloud.admin.api.feign.RemoteLogService; +import com.bsd.pjcloud.common.core.util.AuditLogSanitizer; +import com.bsd.pjcloud.common.core.util.KeyStrResolver; +import com.bsd.pjcloud.common.core.util.SpringContextHolder; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.common.log.aspect.SysLogAspect; +import com.bsd.pjcloud.common.log.config.PjcloudLogProperties; +import com.bsd.pjcloud.common.log.event.SysLogEvent; +import com.bsd.pjcloud.common.log.event.SysLogListener; +import com.bsd.pjcloud.common.log.util.JacksonSensitiveFieldUtil; +import com.bsd.pjcloud.common.log.util.SysLogUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.servlet.HandlerMapping; + +import java.io.ByteArrayInputStream; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class SysLogSanitizationTest { + private GenericApplicationContext context; + private final PjcloudLogProperties properties = new PjcloudLogProperties(); + private final MockHttpServletRequest request = new MockHttpServletRequest(); + private final List events = new ArrayList<>(); + + @BeforeEach + void setup() { + context = new GenericApplicationContext(); + context.getBeanFactory().registerSingleton("logProperties", properties); + context.refresh(); + SpringUtil spring = new SpringUtil(); + spring.postProcessBeanFactory(context.getBeanFactory()); + spring.setApplicationContext(context); + new SpringContextHolder().setApplicationContext(context); + context.addApplicationListener(event -> { + if (event instanceof SysLogEvent logEvent) events.add((SysLogDTO) logEvent.getSource()); + }); + request.setRequestURI("/user/update"); + request.setMethod("POST"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + void cleanup() { + RequestContextHolder.resetRequestAttributes(); + SecurityContextHolder.clearContext(); + SpringContextHolder.clearHolder(); + SpringUtil spring = new SpringUtil(); + spring.postProcessBeanFactory(null); + spring.setApplicationContext(null); + context.close(); + } + + @Test + void requestParametersAndNamedPathCredentialsAreFilteredWithoutChangingTheRequest() { + request.addParameter("PASSWORD", "request-secret"); + request.addParameter("username", "operator"); + request.addParameter("code", "BUS-42"); + request.setRequestURI("/custom/path-secret"); + request.setAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, Map.of("token", "path-secret")); + SysLogDTO record = SysLogUtils.getSysLog(); + assertFalse(record.getParams().contains("request-secret")); + assertTrue(record.getParams().contains("username=operator")); + assertTrue(record.getParams().contains("code=BUS-42")); + assertEquals("/custom/" + AuditLogSanitizer.HIDDEN, record.getRequestUri()); + assertEquals("request-secret", request.getParameter("PASSWORD")); + request.setRequestURI("/auth/oauth2/token"); + assertFalse(SysLogUtils.getSysLog().getParams().contains("code=")); + } + + @Test + void namedArgumentsDtosAndIgnoredRequestAndFileObjectsAreCapturedAsASafeSnapshot() throws Exception { + var method = Sample.class.getMethod("named", String.class, SecretBean.class, HttpServletRequest.class); + var bean = new SecretBean(); + String record = SysLogUtils.captureParameters(method, new Object[]{"named-secret", bean, request}, "/user/update", properties); + var values = new ObjectMapper().readTree(record); + assertFalse(values.has("newpassword1")); + assertFalse(values.has("request")); + assertEquals("operator", values.at("/body/username").textValue()); + assertFalse(record.contains("body-secret")); + bean.username = "changed"; + assertEquals("operator", values.at("/body/username").textValue()); + assertEquals("body-secret", bean.getPassword()); + } + + @Test + void cookieCredentialsAreSkippedEvenWhenTheSessionCookieHasACustomName() throws Exception { + var method = Sample.class.getMethod("cookie", String.class, String.class); + assertEquals("{\"code\":\"BUS-42\"}", SysLogUtils.captureParameters(method, + new Object[]{"cookie-secret", "BUS-42"}, "/orders", properties)); + } + + @Test + void aspectPublishesFilteredSnapshotsBeforeBusinessObjectsCanChange() throws Throwable { + var method = Sample.class.getMethod("update", SecretBean.class); + var body = new SecretBean(); + var signature = mock(MethodSignature.class); + when(signature.getMethod()).thenReturn(method); + when(signature.getName()).thenReturn("update"); + var point = mock(ProceedingJoinPoint.class); + when(point.getTarget()).thenReturn(new Sample()); + when(point.getSignature()).thenReturn(signature); + when(point.getArgs()).thenReturn(new Object[]{body}); + when(point.proceed()).thenAnswer(call -> { + assertEquals("body-secret", body.getPassword()); + body.username = "changed-by-business"; + return "success"; + }); + KeyStrResolver tenant = mock(KeyStrResolver.class); + when(tenant.key()).thenReturn("7"); + assertEquals("success", new SysLogAspect(tenant, properties).around(point, method.getAnnotation(SysLog.class))); + assertEquals(1, events.size()); + assertNull(events.get(0).getBody()); + assertFalse(events.get(0).getParams().contains("body-secret")); + assertTrue(events.get(0).getParams().contains("operator")); + assertFalse(events.get(0).getParams().contains("changed-by-business")); + assertEquals("changed-by-business", body.username); + } + + @Test + void collectorDisabledDoesNotSerializeRequestBodyOrChangeBusinessResults() throws Throwable { + properties.setRequestEnabled(false); + var method = Sample.class.getMethod("update", SecretBean.class); + var signature = mock(MethodSignature.class); + when(signature.getMethod()).thenReturn(method); + when(signature.getName()).thenReturn("update"); + var point = mock(ProceedingJoinPoint.class); + when(point.getTarget()).thenReturn(new Sample()); + when(point.getSignature()).thenReturn(signature); + when(point.getArgs()).thenReturn(new Object[]{new ExplodingBean()}); + when(point.proceed()).thenReturn("success"); + KeyStrResolver tenant = mock(KeyStrResolver.class); + when(tenant.key()).thenReturn("7"); + assertEquals("success", new SysLogAspect(tenant, properties).around(point, method.getAnnotation(SysLog.class))); + assertEquals("", events.get(0).getParams()); + assertNull(events.get(0).getBody()); + } + + @Test + void aspectKeepsTheOriginalBusinessExceptionButDoesNotPublishItsCredentialText() throws Throwable { + var method = Sample.class.getMethod("update", SecretBean.class); + var signature = mock(MethodSignature.class); + when(signature.getMethod()).thenReturn(method); + when(signature.getName()).thenReturn("update"); + var point = mock(ProceedingJoinPoint.class); + when(point.getTarget()).thenReturn(new Sample()); + when(point.getSignature()).thenReturn(signature); + when(point.getArgs()).thenReturn(new Object[]{new SecretBean()}); + var original = new IllegalStateException("password=business-exception-secret"); + when(point.proceed()).thenThrow(original); + KeyStrResolver tenant = mock(KeyStrResolver.class); + when(tenant.key()).thenReturn("7"); + assertSame(original, assertThrows(IllegalStateException.class, + () -> new SysLogAspect(tenant, properties).around(point, method.getAnnotation(SysLog.class)))); + assertEquals("9", events.get(0).getLogType()); + assertEquals("", events.get(0).getException()); + assertFalse(events.get(0).getParams().contains("body-secret")); + assertEquals("password=business-exception-secret", original.getMessage()); + } + + @Test + void listenerFiltersLegacyEventsAndClearsIgnoredObjectsBeforeRemoteWrite() throws Exception { + var remote = mock(RemoteLogService.class); + var listener = new SysLogListener(remote, properties); + var record = new SysLogDTO(); + record.setRequestUri("/user/update?token=uri-token"); + record.setBody(new Object[]{new SecretBean(), null, request, new MockHttpServletResponse(), + mock(BindingResult.class), new MockMultipartFile("file", new byte[]{1}), new ByteArrayInputStream(new byte[]{2})}); + record.setException("upstream Authorization: Bearer error-token"); + listener.saveSysLog(new SysLogEvent(record)); + assertNull(record.getBody()); + assertEquals("/user/update", record.getRequestUri()); + assertFalse(record.getParams().contains("body-secret")); + assertTrue(record.getParams().contains("operator")); + assertEquals(AuditLogSanitizer.HIDDEN, record.getException()); + verify(remote).saveLog(record); + } + + @Test + void serializationFailureAnonymousBodyAndLengthLimitsNeverFallBackToUnfilteredParameters() { + var listener = new SysLogListener(mock(RemoteLogService.class), properties); + for (Object body : List.of(new ExplodingBean(), new Object[]{"anonymous-secret"})) { + var record = new SysLogDTO(); + record.setRequestUri("/orders"); + record.setBody(body); + record.setParams("password=raw-fallback-secret"); + listener.saveSysLog(new SysLogEvent(record)); + assertEquals(AuditLogSanitizer.HIDDEN, record.getParams()); + assertNull(record.getBody()); + } + properties.setMaxLength(10); + var record = new SysLogDTO(); + record.setParams("{\"username\":\"long-business-value\",\"password\":\"secret\"}"); + listener.saveSysLog(new SysLogEvent(record)); + assertEquals(AuditLogSanitizer.HIDDEN, record.getParams()); + } + + @Test + void logFilteringDoesNotExpandTheSerializerUsedBySystemConfiguration() throws Exception { + var listener = new SysLogListener(mock(RemoteLogService.class), properties); + listener.afterPropertiesSet(); + String configuration = "{\"appSecret\":\"config-app-secret\",\"clientSecret\":\"config-client-secret\",\"username\":\"operator\"}"; + String before = JacksonSensitiveFieldUtil.readStr(configuration); + var record = new SysLogDTO(); + record.setParams(configuration); + listener.saveSysLog(new SysLogEvent(record)); + assertEquals("{\"username\":\"operator\"}", record.getParams()); + assertEquals(before, JacksonSensitiveFieldUtil.readStr(configuration)); + assertTrue(before.contains("config-app-secret")); + assertTrue(before.contains("config-client-secret")); + } + + public static class SecretBean { + private String username = "operator"; + public String getUsername() { return username; } + public String getPassword() { return "body-secret"; } + } + + public static class ExplodingBean { + public String getValue() { throw new IllegalStateException("exception-message-containing-a-secret"); } + } + + public static class Sample { + public void named(@RequestParam("newpassword1") String password, @RequestBody SecretBean body, HttpServletRequest request) {} + public void cookie(@CookieValue("CUSTOM_SESSION") String session, @RequestParam("code") String code) {} + @SysLog("修改用户") + public Object update(@RequestBody SecretBean body) { return null; } + } +} diff --git a/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-biz/src/main/java/com/bsd/pjcloud/flow/task/service/impl/RemoteServiceImpl.java b/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-biz/src/main/java/com/bsd/pjcloud/flow/task/service/impl/RemoteServiceImpl.java index b6cf822dd..2d4865598 100644 --- a/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-biz/src/main/java/com/bsd/pjcloud/flow/task/service/impl/RemoteServiceImpl.java +++ b/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-biz/src/main/java/com/bsd/pjcloud/flow/task/service/impl/RemoteServiceImpl.java @@ -112,7 +112,7 @@ public class RemoteServiceImpl implements IRemoteService { Long parentId = checkParentDto.getParentId(); List deptIdList = checkParentDto.getDeptIdList(); // 查询子级包括自己 - List allDept = deptService.getAllDept(null).getData(); + List allDept = deptService.getInnerAllDept(null).getData(); List childrenDeptList = DataUtil.selectChildrenByDept(parentId, allDept); List childrenDeptIdList = childrenDeptList.stream().map(SysDept::getDeptId).collect(Collectors.toList()); @@ -148,7 +148,7 @@ public class RemoteServiceImpl implements IRemoteService { Long childId = checkChildDto.getChildId(); List deptIdList = checkChildDto.getDeptIdList(); // 查询父级包括自己 - List allDept = deptService.getAllDept(null).getData(); + List allDept = deptService.getInnerAllDept(null).getData(); List parentDeptList = DataUtil.selectParentByDept(childId, allDept); List parentDeptIdList = parentDeptList.stream().map(SysDept::getDeptId).collect(Collectors.toList()); parentDeptIdList.remove(childId); diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java index c45b7c2b6..eabdff3ed 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java @@ -18,6 +18,16 @@ import org.springframework.stereotype.Service; */ @Service public class AppVersionServiceImpl extends ServiceImpl implements AppVersionService { + @org.springframework.beans.factory.annotation.Autowired + private com.bsd.pjcloud.common.core.file.FileAccessSigner fileAccessSigner; + + private 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 terminalPageList(Page page, AppVersionDto dto) { - return baseMapper.terminalPageList(page, dto); + Page result = baseMapper.terminalPageList(page, dto); + result.setRecords(result.getRecords().stream().map(this::signDownload).toList()); + return result; } /** diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml b/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml index 3c701c58a..6b1f509fd 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml @@ -48,6 +48,9 @@ a.del_flag = '0' and a.status = '1' + + and a.id = #{dto.id} + and a.app_unique_code = #{dto.appUniqueCode} diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/test/java/com/bsd/pjcloud/integrated/service/AppVersionFileAccessTest.java b/pjcloud-integrated/pjcloud-integrated-biz/src/test/java/com/bsd/pjcloud/integrated/service/AppVersionFileAccessTest.java new file mode 100644 index 000000000..7a09c50e0 --- /dev/null +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/test/java/com/bsd/pjcloud/integrated/service/AppVersionFileAccessTest.java @@ -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(); 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(); 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()); + } +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/config/FileAccessFeignConfiguration.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/config/FileAccessFeignConfiguration.java new file mode 100644 index 000000000..5f74f1c0f --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/config/FileAccessFeignConfiguration.java @@ -0,0 +1,42 @@ +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 ("POST".equals(template.method())) { + String target = switch (template.path()) { + case "/sys-file/upload" -> "upload"; + case "/sys-file/inner-upload" -> "inner-upload"; + case "/sys-file/qn/upload" -> "qn-upload"; + default -> null; + }; + if (target != null) signer.uploadHeaders(target).forEach((key, value) -> { + template.header(key, (String[]) null); + template.header(key, value); + }); + return; + } + if (!"GET".equals(template.method()) || !"/sys-file/oss/file".equals(template.path())) return; + Collection 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); + }); + }; + } +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/DeptExportDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/DeptExportDTO.java new file mode 100644 index 000000000..ce5d41af7 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/DeptExportDTO.java @@ -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; + +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysLogDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysLogDTO.java index f7ef15602..f7f55db4a 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysLogDTO.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysLogDTO.java @@ -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; /** diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysLogExportDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysLogExportDTO.java new file mode 100644 index 000000000..cdeb2d990 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysLogExportDTO.java @@ -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; + +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteClientDetailsService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteClientDetailsService.java index 2003f660b..2133153f5 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteClientDetailsService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteClientDetailsService.java @@ -45,12 +45,12 @@ public interface RemoteClientDetailsService { @GetMapping("/client/getClientDetailsById/{clientId}") R getClientDetailsById(@PathVariable("clientId") String clientId); - /** - * 查询全部客户端 - * @return R - */ - @NoToken - @GetMapping("/client/list") - R> listClientDetails(); +// /** +// * 查询全部客户端 +// * @return R +// */ +// @NoToken +// @GetMapping("/client/list") +// R> listClientDetails(); } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteDeptService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteDeptService.java index 722609be9..caefc06c0 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteDeptService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteDeptService.java @@ -38,12 +38,6 @@ import java.util.Map; @FeignClient(contextId = "remoteDeptService", value = ServiceNameConstants.UPMS_SERVICE) public interface RemoteDeptService { - /** - * 获取所有部门接口 - */ - @GetMapping("/dept/list") - R> getAllDept(@RequestParam("parentDeptId") Long parentDeptId); - /** * 查询所有部门包括被删除的 * 被删除的部门名有(被删除)标注 diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteFileService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteFileService.java index 6745766a5..9d7aeb89a 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteFileService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteFileService.java @@ -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) diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysLogExcelVO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysLogExcelVO.java new file mode 100644 index 000000000..4f5b88f8e --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysLogExcelVO.java @@ -0,0 +1,71 @@ +package com.bsd.pjcloud.admin.api.vo; + +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.format.DateTimeFormat; +import com.alibaba.excel.annotation.format.NumberFormat; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import com.alibaba.excel.converters.localdatetime.LocalDateTimeNumberConverter; +import com.alibaba.excel.converters.longconverter.LongNumberConverter; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 日志导出专用列,常用信息在前,长文本与精确编号在后。 */ +@Data +@ExcelIgnoreUnannotated +public class SysLogExcelVO { + @ExcelProperty(value = "创建时间", index = 0, converter = LocalDateTimeNumberConverter.class) + @DateTimeFormat("yyyy-mm-dd hh:mm:ss") + @ColumnWidth(32) + private LocalDateTime createTime; + + @ExcelProperty(value = "日志类型", index = 1) + @ColumnWidth(12) + private String logType; + + @ExcelProperty(value = "日志标题", index = 2) + @ColumnWidth(28) + private String title; + + @ExcelProperty(value = "操作人", index = 3) + @ColumnWidth(18) + private String createBy; + + @ExcelProperty(value = "所属应用", index = 4) + @ColumnWidth(28) + private String serviceId; + + @ExcelProperty(value = "请求方式", index = 5) + @ColumnWidth(12) + private String method; + + @ExcelProperty(value = "请求地址", index = 6) + @ColumnWidth(42) + private String requestUri; + + @ExcelProperty(value = "耗时(毫秒)", index = 7, converter = LongNumberConverter.class) + @NumberFormat("#,##0") + @ColumnWidth(16) + private Long time; + + @ExcelProperty(value = "IP 地址", index = 8) + @ColumnWidth(42) + private String remoteAddr; + + @ExcelProperty(value = "提交数据", index = 9) + @ColumnWidth(64) + private String params; + + @ExcelProperty(value = "异常详情", index = 10) + @ColumnWidth(64) + private String exception; + + @ExcelProperty(value = "用户代理", index = 11) + @ColumnWidth(48) + private String userAgent; + + @ExcelProperty(value = "日志编号", index = 12) + @ColumnWidth(32) + private String id; +} diff --git a/pjcloud-upms/pjcloud-upms-biz/pom.xml b/pjcloud-upms/pjcloud-upms-biz/pom.xml index b49e28c8f..501130508 100644 --- a/pjcloud-upms/pjcloud-upms-biz/pom.xml +++ b/pjcloud-upms/pjcloud-upms-biz/pom.xml @@ -204,6 +204,11 @@ org.springframework.boot spring-boot-starter-test + + com.h2database + h2 + test + com.bsd pjcloud-wallet-api diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysAuditLogController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysAuditLogController.java index b0f1c99c6..f711ec32a 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysAuditLogController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysAuditLogController.java @@ -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 auditLogList) { - return R.ok(sysAuditLogService.saveBatch(auditLogList)); - } + /** + * 新增审计记录表 (异步插入) + * + * @param auditLogList 审计记录 + * @return R + */ + @Inner + @PostMapping + @Operation(summary = "新增审计记录表", description = "新增审计记录表") + public R save(@RequestBody List 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 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 export(SysAuditLog sysAuditLog, Long[] ids) { + return sysAuditLogService + .list(Wrappers.lambdaQuery(sysAuditLog).in(ArrayUtil.isNotEmpty(ids), SysAuditLog::getId, ids)); + } } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDeptController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDeptController.java index 2eb197650..12eda5385 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDeptController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDeptController.java @@ -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 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 exportAsync(@RequestBody DeptExportDTO query) { + return R.ok(batchExportService.submit(query), "部门导出任务已提交"); } /** diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDictController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDictController.java index fa82381bd..454838ee6 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDictController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDictController.java @@ -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 getDictPage(@ParameterObject Page page, @ParameterObject SysDict sysDict) { return R.ok(sysDictService.page(page, Wrappers.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.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 export(SysDictItem sysDictItem) { return sysDictItemService.list(Wrappers.query(sysDictItem)); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java index 55dedf43c..fbe1149c9 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java @@ -34,6 +34,11 @@ 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.admin.service.file.FileUploadGuard; +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,13 +64,18 @@ import java.util.Objects; public class SysFileController { private final SysFileService sysFileService; + private final FileAccessGuard fileAccessGuard; + private final FileAccessProperties fileAccessProperties; + private final FileUploadGuard fileUploadGuard; /* ************************************* start 远程调用 start ************************************* */ @Inner - @PostMapping(value = "/inner-upload") + @PostMapping(value = "/inner-upload", consumes = "multipart/form-data") public R innerUpload(@RequestPart("file") MultipartFile file, @RequestParam(value = "dir", required = false) String dir, @RequestParam(value = "groupId", required = false) Long groupId, - @RequestParam(value = "type", required = false) String type) { + @RequestParam(value = "type", required = false) String type, + HttpServletRequest request, HttpServletResponse response) throws IOException { + if (!fileUploadGuard.authorize(request, response, "inner-upload")) return null; return sysFileService.innerUpload(file, dir, groupId, type); } /* ************************************* end 远程调用 end ************************************* */ @@ -116,18 +126,22 @@ public class SysFileController { * @param dir 文件夹 * @return R(/ admin / bucketName / filename) */ - @PostMapping(value = "/upload") + @PostMapping(value = "/upload", consumes = "multipart/form-data") public R upload(@RequestPart("file") MultipartFile file, @RequestParam(value = "dir", required = false) String dir, @RequestParam(value = "groupId", required = false) Long groupId, - @RequestParam(value = "type", required = false) String type) { + @RequestParam(value = "type", required = false) String type, + HttpServletRequest request, HttpServletResponse response) throws IOException { + if (!fileUploadGuard.authorize(request, response, "upload")) return null; return sysFileService.uploadFile(file, dir, groupId, type); } /** * 七牛云上传 */ - @PostMapping(value = "/qn/upload") - public R> qnUpload(@RequestPart("file") MultipartFile file, @RequestParam(value = "dir", required = false) String dir, @RequestParam(value = "groupId", required = false) Long groupId, @RequestParam(value = "type", required = false) String type) { + @PostMapping(value = "/qn/upload", consumes = "multipart/form-data") + public R> qnUpload(@RequestPart("file") MultipartFile file, @RequestParam(value = "dir", required = false) String dir, @RequestParam(value = "groupId", required = false) Long groupId, @RequestParam(value = "type", required = false) String type, + HttpServletRequest request, HttpServletResponse response) throws IOException { + if (!fileUploadGuard.authorize(request, response, "qn-upload")) return null; return sysFileService.qnUpload(file, dir, groupId, type); } @@ -140,8 +154,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> fileAccessInfo(HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + return R.ok(Map.of("serverTime", System.currentTimeMillis(), "ttlSeconds", fileAccessProperties.getTtlSeconds())); } /** diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysLogController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysLogController.java index 9c282a65f..0fdcbbe84 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysLogController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysLogController.java @@ -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; /** *

@@ -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 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 exportAsync(@RequestBody SysLogExportDTO sysLog) { + return R.ok(batchExportService.submit(sysLog), "日志导出任务已提交"); } } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 96a0269d5..821f63cdc 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -370,9 +370,6 @@ public class SysUserController { /** * 导出excel 表格 - * - * @param userDTO 查询条件 - * @return */ @PostMapping("/export") @HasPermission("sys_user_export") @@ -389,10 +386,6 @@ public class SysUserController { /** * 导入用户 - * - * @param excelVOList 用户列表 - * @param bindingResult 错误信息列表 - * @return R */ @PostMapping("/import") @HasPermission("sys_user_export") diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysDeptMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysDeptMapper.java index ea117d28f..4c5fd94e7 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysDeptMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysDeptMapper.java @@ -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 { List allList(@Param("dto") DeptAllListDto dto); - + + /** 导出按部门数据权限分页,同时关联上级部门名称。 */ + Page selectExportPageByScope(Page page, @Param("query") DeptExportDTO query, + DataScope dataScope); + } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysDeptService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysDeptService.java index 212444e7a..f9920b1af 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysDeptService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysDeptService.java @@ -72,8 +72,6 @@ public interface SysDeptService extends IService { */ Boolean create(@Valid SysDept sysDept); - List listExcelVo(); - R importDept(List excelVOList, BindingResult bindingResult); /** diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java new file mode 100644 index 000000000..ea9391760 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java @@ -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 APPS = Set.of("admin", "h5", "mini", "flutter", "service", "package"); + private static final List 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 lastLogs = new ConcurrentHashMap<>(); + + @org.springframework.beans.factory.annotation.Autowired + public FileAccessGuard(FileAccessProperties properties, ObjectProvider 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 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; + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadExceptionHandler.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadExceptionHandler.java new file mode 100644 index 000000000..013c6d71d --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadExceptionHandler.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.admin.service.file; + +import com.bsd.pjcloud.admin.controller.SysFileController; +import com.bsd.pjcloud.common.core.util.R; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Order(-100) +@RestControllerAdvice(assignableTypes = SysFileController.class) +public class FileUploadExceptionHandler { + @ExceptionHandler(FileUploadRejectedException.class) + public ResponseEntity> rejected(FileUploadRejectedException error) { + return ResponseEntity.status(error.getStatus()).header("Cache-Control", "no-store").body(R.failed(error.getMessage())); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadGuard.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadGuard.java new file mode 100644 index 000000000..c5f7e0786 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadGuard.java @@ -0,0 +1,83 @@ +package com.bsd.pjcloud.admin.service.file; + +import com.bsd.pjcloud.common.core.file.FileAccessProperties; +import com.bsd.pjcloud.common.core.file.FileAccessSignature; +import com.bsd.pjcloud.common.core.file.FileUploadSignature; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.util.*; + +@Component +public class FileUploadGuard { + private static final Set APPS = Set.of("admin", "h5", "mini", "flutter", "service"); + private static final List HEADERS = List.of(FileUploadSignature.APP, FileUploadSignature.KID, + FileUploadSignature.TIMESTAMP, FileUploadSignature.NONCE, FileUploadSignature.SIGNATURE); + private final FileAccessProperties properties; + private final FileUploadNonceStore nonces; + private final MeterRegistry meters; + private final Clock clock; + + @org.springframework.beans.factory.annotation.Autowired + public FileUploadGuard(FileAccessProperties properties, FileUploadNonceStore nonces, ObjectProvider meters) { + this(properties, nonces, meters.getIfAvailable(), Clock.systemUTC()); + } + public FileUploadGuard(FileAccessProperties properties, FileUploadNonceStore nonces, MeterRegistry meters, Clock clock) { + this.properties = properties; this.nonces = nonces; this.meters = meters; this.clock = clock; + } + public record Decision(int status, String reason) { } + + public Decision evaluate(HttpServletRequest request, String target) { + properties.validate(); + if (!"POST".equals(request.getMethod()) || !FileUploadSignature.TARGETS.contains(target)) return new Decision(400, "invalid_upload_target"); + if (request instanceof org.springframework.web.multipart.MultipartHttpServletRequest multipart + && (multipart.getMultiFileMap().values().stream().mapToLong(List::size).sum() != 1 + || !multipart.getMultiFileMap().containsKey("file"))) return new Decision(400, "invalid_upload_part_count"); + for (String name : HEADERS) { + List values = Collections.list(request.getHeaders(name)); + if (values.isEmpty()) return new Decision(403, "missing_upload_signature"); + if (values.size() != 1) return new Decision(400, "duplicate_upload_header"); + } + String app = request.getHeader(FileUploadSignature.APP), kid = request.getHeader(FileUploadSignature.KID), + ts = request.getHeader(FileUploadSignature.TIMESTAMP), nonce = request.getHeader(FileUploadSignature.NONCE), + signature = request.getHeader(FileUploadSignature.SIGNATURE); + if (!APPS.contains(app)) return new Decision(403, "unknown_upload_client"); + if (!kid.matches("[A-Za-z0-9_-]{1,32}") || !ts.matches("(?:0|[1-9][0-9]{0,11})") + || !nonce.matches("[A-Za-z0-9_-]{16,64}") || !signature.matches("[0-9a-f]{64}")) return new Decision(400, "malformed_upload_signature"); + if (target.equals("inner-upload") && !app.equals("service")) return new Decision(403, "internal_upload_only"); + String key = properties.key(app, kid); + if (key == null || key.isBlank()) return new Decision(403, "unknown_upload_key"); + long issued = Long.parseLong(ts), now = clock.instant().getEpochSecond(), ttl = properties.getUpload().getTtlSeconds(); + if (issued > now + Math.min(60, properties.getFutureSkewSeconds())) return new Decision(403, "upload_clock_skew"); + if (now - issued >= ttl) return new Decision(403, "upload_signature_expired"); + if (!FileAccessSignature.matches(FileUploadSignature.sign(target, app, kid, issued, nonce, key), signature)) return new Decision(403, "invalid_upload_signature"); + if (properties.enforcing() && !nonces.claim(app, kid, nonce, Duration.ofSeconds(issued + ttl - now + 1))) { + return new Decision(403, "upload_signature_replayed"); + } + return new Decision(200, "upload_signed"); + } + + public boolean authorize(HttpServletRequest request, HttpServletResponse response, String target) throws IOException { + Decision decision; + try { decision = evaluate(request, target); } + catch (RuntimeException error) { decision = new Decision(500, "upload_authorization_unavailable"); } + String app = request.getHeader(FileUploadSignature.APP); + String mode = Objects.toString(properties.getMode(), "invalid"); + if (!Set.of("observe", "enforce").contains(mode)) mode = "invalid"; + if (meters != null) meters.counter("file.upload.requests", "outcome", decision.reason(), "app", APPS.contains(Objects.toString(app, "")) ? app : "unknown", + "mode", mode).increment(); + response.setHeader("Cache-Control", "no-store"); + if (decision.status() != 200 && (properties.enforcing() || decision.status() == 500)) { + response.setStatus(decision.status()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":" + decision.status() + ",\"msg\":\"上传身份校验失败\",\"reason\":\"" + decision.reason() + "\"}"); + return false; + } + return true; + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadNonceStore.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadNonceStore.java new file mode 100644 index 000000000..3a39f533b --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadNonceStore.java @@ -0,0 +1,8 @@ +package com.bsd.pjcloud.admin.service.file; + +import java.time.Duration; + +@FunctionalInterface +public interface FileUploadNonceStore { + boolean claim(String appId, String kid, String nonce, Duration lifetime); +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadPolicy.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadPolicy.java new file mode 100644 index 000000000..fa143cf66 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadPolicy.java @@ -0,0 +1,228 @@ +package com.bsd.pjcloud.admin.service.file; + +import com.bsd.pjcloud.common.core.file.FileAccessProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.MemoryCacheImageInputStream; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.*; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** Checks the server-side allowlist and actual bytes before any storage or file log is written. */ +@Component +@RequiredArgsConstructor +public class FileUploadPolicy { + private final FileAccessProperties properties; + public record Accepted(String extension, String contentType) { } + private static final Pattern UNSAFE_CSS = Pattern.compile("(?is)@import|expression\\s*\\("); + private static final Pattern CSS_URL = Pattern.compile("(?is)url\\s*\\(([^)]*)\\)"); + private static final Set RASTER_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "bmp", "webp"); + + public Accepted validate(MultipartFile file, String dir, boolean internal) { + if (file == null || file.isEmpty() || file.getSize() <= 0) reject(400, "上传文件不能为空"); + String name = file.getOriginalFilename(); + if (name == null || name.isBlank() || name.length() > 512 || name.chars().anyMatch(c -> c < 32 || c == 127) + || name.contains("/") || name.contains("\\")) reject(400, "文件名不合法"); + if (dir != null && (dir.length() > 256 || dir.chars().anyMatch(c -> c < 32 || c == 127) || dir.contains("\\") + || Arrays.stream(dir.split("/", -1)).anyMatch(s -> s.equals(".") || s.equals("..")))) reject(400, "文件目录不合法"); + int dot = name.lastIndexOf('.'); + String ext = dot < 1 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT); + var policy = properties.getUpload(); + if (!allowedExtension(ext, internal)) reject(415, "不支持上传此文件类型"); + if (file.getSize() > policy.getMaxFileBytes()) reject(413, "文件超过服务器上传大小限制"); + try (InputStream input = file.getInputStream()) { + byte[] head = input.readNBytes(4096); + if (RASTER_EXTENSIONS.contains(ext)) { + String actual = rasterExtension(head); + if (actual != null) { + if (actual.equals("jpg") && (ext.equals("jpeg") || !allowedExtension("jpg", internal))) actual = "jpeg"; + if (!allowedExtension(actual, internal)) reject(415, "图片实际格式为 " + actual.toUpperCase(Locale.ROOT) + ",服务器未允许上传此类型"); + // Clipboard/download tools can name WebP bytes .png. Keep the + // original bytes and normalize only to a verified allowed image type. + ext = actual; + } + } + boolean valid = switch (ext) { + case "jpg", "jpeg" -> starts(head, 0xff, 0xd8, 0xff) && imageDimensions(file); + case "png" -> starts(head, 137, 80, 78, 71, 13, 10, 26, 10) && imageDimensions(file); + case "gif" -> (ascii(head, 0, "GIF87a") || ascii(head, 0, "GIF89a")) && imageDimensions(file); + case "bmp" -> ascii(head, 0, "BM") && imageDimensions(file); + case "webp" -> ascii(head, 0, "RIFF") && ascii(head, 8, "WEBP"); + case "ico" -> starts(head, 0, 0, 1, 0) && head.length >= 6 && ((head[4] & 255) | (head[5] & 255) << 8) > 0; + case "svg" -> safeSvg(file); + case "pdf" -> ascii(head, 0, "%PDF-"); + case "apk", "zip", "docx", "xlsx", "pptx" -> starts(head, 80, 75, 3, 4) && validArchive(file, ext); + case "doc", "xls", "ppt" -> starts(head, 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1); + case "txt", "csv", "log" -> validText(file); + case "gz" -> starts(head, 0x1f, 0x8b, 8); + case "mp4", "mov", "m4a" -> ascii(head, 4, "ftyp"); + case "avi" -> ascii(head, 0, "RIFF") && ascii(head, 8, "AVI "); + case "wav" -> ascii(head, 0, "RIFF") && ascii(head, 8, "WAVE"); + case "webm" -> starts(head, 0x1a, 0x45, 0xdf, 0xa3); + case "mp3" -> ascii(head, 0, "ID3") || (head.length > 1 && (head[0] & 255) == 255 && (head[1] & 0xe0) == 0xe0); + case "aac" -> head.length > 1 && (head[0] & 255) == 255 && (head[1] & 0xf6) == 0xf0; + case "ogg" -> ascii(head, 0, "OggS"); + case "amr" -> ascii(head, 0, "#!AMR\n") || ascii(head, 0, "#!AMR-WB\n"); + case "flac" -> ascii(head, 0, "fLaC"); + default -> false; + }; + if (!valid) reject(415, "文件内容与后缀类型不符,或文件格式不安全"); + return new Accepted(ext, contentType(ext)); + } catch (FileUploadRejectedException error) { throw error; } + catch (Exception error) { throw new FileUploadRejectedException(415, "无法识别文件内容或文件已损坏"); } + } + + private boolean allowedExtension(String extension, boolean internal) { + var policy = properties.getUpload(); + return policy.getAllowedExtensions().contains(extension) || (internal && policy.getInternalExtensions().contains(extension)); + } + + private static String rasterExtension(byte[] head) { + if (starts(head, 137, 80, 78, 71, 13, 10, 26, 10)) return "png"; + if (starts(head, 0xff, 0xd8, 0xff)) return "jpg"; + if (ascii(head, 0, "GIF87a") || ascii(head, 0, "GIF89a")) return "gif"; + if (ascii(head, 0, "BM")) return "bmp"; + if (ascii(head, 0, "RIFF") && ascii(head, 8, "WEBP")) return "webp"; + return null; + } + + private boolean imageDimensions(MultipartFile file) throws IOException { + try (var stream = new MemoryCacheImageInputStream(file.getInputStream())) { + Iterator readers = ImageIO.getImageReaders(stream); + if (!readers.hasNext()) return false; + ImageReader reader = readers.next(); + try { + reader.setInput(stream, true, true); + int width = reader.getWidth(0), height = reader.getHeight(0); + return width > 0 && height > 0 && width <= 30000 && height <= 30000 && (long) width * height <= 100_000_000; + } finally { reader.dispose(); } + } + } + + private boolean validText(MultipartFile file) throws IOException { + var decoder = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT); + try (Reader reader = new InputStreamReader(file.getInputStream(), decoder)) { + char[] buffer = new char[8192]; int count; + while ((count = reader.read(buffer)) >= 0) for (int i = 0; i < count; i++) { + char c = buffer[i]; + if (c < 32 && c != '\r' && c != '\n' && c != '\t' && c != '\f' && c != 27) return false; + } + return true; + } + } + + private boolean validArchive(MultipartFile file, String extension) throws IOException { + Path temporary = Files.createTempFile("file-upload-check-", ".zip"); + try { + try (InputStream input = file.getInputStream(); OutputStream output = Files.newOutputStream(temporary)) { + byte[] buffer = new byte[8192]; long copied = 0; int count; + while ((count = input.read(buffer)) >= 0) { + copied += count; + if (copied > properties.getUpload().getMaxFileBytes()) reject(413, "文件超过服务器上传大小限制"); + output.write(buffer, 0, count); + } + } + try (ZipFile archive = new ZipFile(temporary.toFile())) { + long expanded = 0; int entries = 0; + for (Enumeration e = archive.entries(); e.hasMoreElements();) { + ZipEntry entry = e.nextElement(); String name = entry.getName().replace('\\', '/'); + if (++entries > 50000 || name.startsWith("/") || name.matches("^[A-Za-z]:.*") + || Arrays.asList(name.split("/")).contains("..")) return false; + expanded += Math.max(0, entry.getSize()); + if (expanded > 1024L * 1024 * 1024) return false; + } + return switch (extension) { + case "zip" -> entries > 0; + case "docx" -> archive.getEntry("[Content_Types].xml") != null && archive.getEntry("word/document.xml") != null; + case "xlsx" -> archive.getEntry("[Content_Types].xml") != null && archive.getEntry("xl/workbook.xml") != null; + case "pptx" -> archive.getEntry("[Content_Types].xml") != null && archive.getEntry("ppt/presentation.xml") != null; + case "apk" -> { + ZipEntry manifest = archive.getEntry("AndroidManifest.xml"); + if (manifest == null) yield false; + try (InputStream input = archive.getInputStream(manifest)) { yield starts(input.readNBytes(4), 3, 0, 8, 0); } + } + default -> false; + }; + } + } finally { Files.deleteIfExists(temporary); } + } + + private boolean safeSvg(MultipartFile file) throws Exception { + if (file.getSize() > 1024 * 1024) reject(413, "SVG 文件不能超过 1MB"); + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); factory.setXIncludeAware(false); factory.setExpandEntityReferences(false); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setAttribute("http://www.oracle.com/xml/jaxp/properties/maxElementDepth", "256"); + factory.setAttribute("http://www.oracle.com/xml/jaxp/properties/elementAttributeLimit", "256"); + try (InputStream input = file.getInputStream()) { + var document = factory.newDocumentBuilder().parse(input); + Element root = document.getDocumentElement(); + if (!"svg".equals(root.getLocalName()) || !"http://www.w3.org/2000/svg".equals(root.getNamespaceURI())) return false; + Deque nodes = new ArrayDeque<>(); nodes.push(document); int count = 0; + while (!nodes.isEmpty()) { + Node node = nodes.pop(); + if (++count > 20000 || node.getNodeType() == Node.PROCESSING_INSTRUCTION_NODE) return false; + String local = Objects.toString(node.getLocalName(), "").toLowerCase(Locale.ROOT); + if (Set.of("script", "foreignobject", "iframe", "object", "embed", "animate", "set", "animatetransform", "animatemotion").contains(local)) return false; + if (local.equals("style") && unsafeCss(node.getTextContent())) return false; + if (node.hasAttributes()) for (int i = 0; i < node.getAttributes().getLength(); i++) { + Node attr = node.getAttributes().item(i); + String name = Objects.toString(attr.getLocalName(), attr.getNodeName()).toLowerCase(Locale.ROOT), value = attr.getNodeValue().trim(); + if (name.startsWith("on") || name.equals("base") || unsafeCss(value)) return false; + if (name.equals("href") && !value.startsWith("#") + && !value.matches("(?is)data:image/(?:png|jpeg|gif|webp);base64,[a-z0-9+/=\\s]+")) return false; + } + for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) nodes.push(child); + } + return true; + } + } + + private static boolean unsafeCss(String value) { + if (value.contains("\\") || UNSAFE_CSS.matcher(value).find()) return true; + var urls = CSS_URL.matcher(value); + while (urls.find()) { + String reference = urls.group(1).trim().replaceAll("^['\"]|['\"]$", ""); + if (!reference.startsWith("#")) return true; + } + return false; + } + + private static boolean starts(byte[] value, int... prefix) { + if (value.length < prefix.length) return false; + for (int i = 0; i < prefix.length; i++) if ((value[i] & 255) != prefix[i]) return false; + return true; + } + private static boolean ascii(byte[] value, int offset, String marker) { + return value.length >= offset + marker.length() && new String(value, offset, marker.length(), StandardCharsets.US_ASCII).equals(marker); + } + private static void reject(int status, String message) { throw new FileUploadRejectedException(status, message); } + private static String contentType(String ext) { + return switch (ext) { + case "jpg", "jpeg" -> "image/jpeg"; case "png" -> "image/png"; case "gif" -> "image/gif"; + case "bmp" -> "image/bmp"; case "webp" -> "image/webp"; case "ico" -> "image/x-icon"; case "svg" -> "image/svg+xml"; + case "pdf" -> "application/pdf"; case "apk" -> "application/vnd.android.package-archive"; + case "zip" -> "application/zip"; case "gz" -> "application/gzip"; + case "txt", "log" -> "text/plain;charset=UTF-8"; case "csv" -> "text/csv;charset=UTF-8"; + case "mp4", "mov" -> "video/mp4"; case "webm" -> "video/webm"; case "avi" -> "video/x-msvideo"; + case "mp3" -> "audio/mpeg"; case "wav" -> "audio/wav"; case "m4a" -> "audio/mp4"; case "aac" -> "audio/aac"; + case "ogg" -> "audio/ogg"; case "amr" -> "audio/amr"; case "flac" -> "audio/flac"; + default -> "application/octet-stream"; + }; + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadRejectedException.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadRejectedException.java new file mode 100644 index 000000000..b8c83a6ab --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadRejectedException.java @@ -0,0 +1,7 @@ +package com.bsd.pjcloud.admin.service.file; + +public class FileUploadRejectedException extends RuntimeException { + private final int status; + public FileUploadRejectedException(int status, String message) { super(message); this.status = status; } + public int getStatus() { return status; } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/RedisFileUploadNonceStore.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/RedisFileUploadNonceStore.java new file mode 100644 index 000000000..2a3d05047 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/RedisFileUploadNonceStore.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.admin.service.file; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class RedisFileUploadNonceStore implements FileUploadNonceStore { + private final StringRedisTemplate redis; + + @Override + public boolean claim(String appId, String kid, String nonce, Duration lifetime) { + return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent("file-upload:nonce:" + appId + ':' + kid + ':' + nonce, "1", lifetime)); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptBatchExportService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptBatchExportService.java new file mode 100644 index 000000000..3b42a8f6c --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptBatchExportService.java @@ -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 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 generate(DeptExportDTO query, AsyncTaskProgressReporter reporter, ExcelExportStyleHandler style, + BatchExcelExportTemplate.FileConsumer consumer) throws Exception { + Page 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 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 fetch(DeptExportDTO query, long current) { + Page 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); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptServiceImpl.java index 0342876a0..4a23f4653 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptServiceImpl.java @@ -261,29 +261,6 @@ public class SysDeptServiceImpl extends ServiceImpl impl } - /** - * 导出部门 - * - * @return - */ - @Override - public List listExcelVo() { - List list = this.list(); - List deptExcelVos = list.stream().map(item -> { - DeptExcelVO deptExcelVo = new DeptExcelVO(); - deptExcelVo.setName(item.getName()); - Optional 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 excelVOList, BindingResult bindingResult) { List errorMessageList = (List) bindingResult.getTarget(); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java index 97d7b1405..ab8f58ada 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysFileServiceImpl.java @@ -38,6 +38,7 @@ import com.bsd.pjcloud.admin.mapper.SysFileGroupMapper; import com.bsd.pjcloud.admin.mapper.SysFileMapper; import com.bsd.pjcloud.admin.service.QnOssService; import com.bsd.pjcloud.admin.service.SysFileService; +import com.bsd.pjcloud.admin.service.file.FileUploadPolicy; import com.bsd.pjcloud.common.core.constant.CommonConstants; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; @@ -76,6 +77,7 @@ public class SysFileServiceImpl extends ServiceImpl impl private final QnOssConfig qnOssConfig; private final QnOssService qnOssService; + private final FileUploadPolicy uploadPolicy; /** * 上传文件 @@ -88,14 +90,15 @@ public class SysFileServiceImpl extends ServiceImpl impl */ @Override public R uploadFile(MultipartFile file, String dir, Long groupId, String type) { - String fileName = IdUtil.simpleUUID() + StrUtil.DOT + FileUtil.extName(file.getOriginalFilename()); + var accepted = uploadPolicy.validate(file, dir, false); + String fileName = IdUtil.simpleUUID() + StrUtil.DOT + accepted.extension(); Map resultMap = new HashMap<>(4); resultMap.put("bucketName", properties.getBucketName()); resultMap.put("fileName", fileName); resultMap.put("url", String.format("/admin/sys-file/oss/file?fileName=%s", fileName)); try (InputStream inputStream = file.getInputStream()) { - fileTemplate.putObject(properties.getBucketName(), dir, fileName, inputStream, file.getContentType()); + fileTemplate.putObject(properties.getBucketName(), dir, fileName, inputStream, accepted.contentType()); // 文件管理数据记录,收集管理追踪文件 fileLog(file, dir, fileName, groupId, type); } catch (Exception e) { @@ -108,13 +111,14 @@ public class SysFileServiceImpl extends ServiceImpl impl @Override public R innerUpload(MultipartFile file, String dir, Long groupId, String type) { - String fileName = IdUtil.simpleUUID() + StrUtil.DOT + FileUtil.extName(file.getOriginalFilename()); + var accepted = uploadPolicy.validate(file, dir, true); + String fileName = IdUtil.simpleUUID() + StrUtil.DOT + accepted.extension(); Map resultMap = new HashMap<>(5); resultMap.put("bucketName", properties.getBucketName()); resultMap.put("fileName", fileName); resultMap.put("url", String.format("/admin/sys-file/oss/file?fileName=%s", fileName)); try (InputStream inputStream = file.getInputStream()) { - fileTemplate.putObject(properties.getBucketName(), dir, fileName, inputStream, file.getContentType()); + fileTemplate.putObject(properties.getBucketName(), dir, fileName, inputStream, accepted.contentType()); // 文件管理数据记录,收集管理追踪文件 SysFile sysFile = innerFileLog(file, dir, fileName, groupId, type); // 内部上传调用方需要文件中心主键执行后续替换、清理和保留周期管理。 @@ -130,7 +134,8 @@ public class SysFileServiceImpl extends ServiceImpl impl */ @Override public R> qnUpload(MultipartFile file, String dir, Long groupId, String type) { - String fileName = IdUtil.simpleUUID() + StrUtil.DOT + FileUtil.extName(file.getOriginalFilename()); + var accepted = uploadPolicy.validate(file, dir, false); + String fileName = IdUtil.simpleUUID() + StrUtil.DOT + accepted.extension(); Map resultMap = new HashMap<>(4); QnUploadVo upload = qnOssService.upload(file, fileName); @@ -149,19 +154,47 @@ public class SysFileServiceImpl extends ServiceImpl impl */ @Override public void getFile(String fileName, HttpServletResponse response) { - TenantContextHolder.setTenantSkip(); - SysFile sysFile = baseMapper.selectOne(Wrappers.lambdaQuery().eq(SysFile::getFileName, fileName)); + SysFile sysFile; + boolean previousSkip = TenantContextHolder.getTenantSkip(); + try { + TenantContextHolder.setTenantSkip(); + sysFile = baseMapper.selectOne(Wrappers.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) { } + } + /** * 删除文件 * @@ -279,7 +312,7 @@ public class SysFileServiceImpl extends ServiceImpl impl sysFile.setType(type); sysFile.setGroupId(groupId); - sysFile.setHash(SecureUtil.md5(file.getInputStream())); + try (InputStream hashInput = file.getInputStream()) { sysFile.setHash(SecureUtil.md5(hashInput)); } // 调用save方法保存SysFile对象 this.save(sysFile); } @@ -301,7 +334,7 @@ public class SysFileServiceImpl extends ServiceImpl impl sysFile.setType(type); sysFile.setGroupId(groupId); - sysFile.setHash(SecureUtil.md5(file.getInputStream())); + try (InputStream hashInput = file.getInputStream()) { sysFile.setHash(SecureUtil.md5(hashInput)); } sysFile.setCreateBy("SYSTEM"); sysFile.setUpdateBy("SYSTEM"); // 调用save方法保存SysFile对象 diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysLogBatchExportService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysLogBatchExportService.java new file mode 100644 index 000000000..6b12045f0 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysLogBatchExportService.java @@ -0,0 +1,160 @@ +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.api.vo.SysLogExcelVO; +import com.bsd.pjcloud.admin.mapper.SysLogMapper; +import com.bsd.pjcloud.admin.service.SysFileService; +import com.bsd.pjcloud.admin.service.log.SysLogSanitizer; +import com.bsd.pjcloud.admin.utils.excel.SysLogExportStyleHandler; +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.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 org.springframework.beans.BeanUtils; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** 日志按主键游标分批读取,同步和异步导出共用原有列及统一 Excel 样式。 */ +@Service +@RequiredArgsConstructor +public class SysLogBatchExportService { + private static final String FILE_NAME = "操作日志.xlsx"; + + private final SysLogMapper mapper; + private final SysFileService files; + private final AsyncTaskExecutorService tasks; + private final ExcelConfigProperties excelConfig; + private final SysLogSanitizer logSanitizer; + + public AsyncTaskProgressVO submit(SysLogExportDTO dto) { + SysLogExportDTO query = snapshot(dto); + ExcelExportStyleHandler style = new SysLogExportStyleHandler(excelConfig.getExportStyle(), query.getCreateTime()); + 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 SysLogExportStyleHandler(excelConfig.getExportStyle(), query.getCreateTime()); + generate(query, null, style, file -> BatchExportSupport.respond(file, FILE_NAME, response)); + } + + private SysLogExportDTO snapshot(SysLogExportDTO dto) { + LocalDateTime[] range = dto == null ? null : dto.getCreateTime(); + if (range == null || range.length != 2 || range[0] == null || range[1] == null) { + throw new IllegalArgumentException("导出前请选择完整的日志创建时间范围"); + } + if (range[0].isAfter(range[1])) { + throw new IllegalArgumentException("日志创建时间的开始时间不能晚于结束时间"); + } + if (range[1].isAfter(range[0].plusMonths(1))) { + throw new IllegalArgumentException("日志导出时间范围不能超过一个月"); + } + SysLogExportDTO query = BeanUtil.toBean(dto, SysLogExportDTO.class); + query.setCreateTime(range.clone()); + query.setIds(dto.getIds() == null ? null : dto.getIds().clone()); + return query; + } + + private T generate(SysLogExportDTO query, AsyncTaskProgressReporter reporter, ExcelExportStyleHandler style, + BatchExcelExportTemplate.FileConsumer consumer) throws Exception { + Page first = fetch(query, null); + long total = first.getTotal(); + if (total == 0) throw new AsyncTaskException("当前筛选条件没有可导出的日志"); + int maximum = BatchExcelExportTemplate.calculateMaxDetailRowCount(SysLogExportStyleHandler.DATA_START_ROW, 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(SysLogExcelVO.class) + .relativeHeadRowIndex(SysLogExportStyleHandler.HEADER_ROW) + .registerWriteHandler(style).build()) { + WriteSheet sheet = EasyExcel.writerSheet("操作日志").build(); + List rows = first.getRecords(); + while (!rows.isEmpty()) { + written += rows.size(); + if (written > total) throw new AsyncTaskException("导出期间日志数据发生变化,请重试"); + writer.write(rows.stream().map(this::convert).toList(), 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 SysLogExcelVO convert(SysLog log) { + SysLogExcelVO row = new SysLogExcelVO(); + BeanUtils.copyProperties(logSanitizer.copy(log), row); + row.setId(log.getId() == null ? null : log.getId().toString()); + if ("0".equals(log.getLogType())) row.setLogType("正常"); + else if ("9".equals(log.getLogType())) row.setLogType("错误"); + return row; + } + + private Page fetch(SysLogExportDTO query, Long lastId) { + // 各批次都只取游标后的前 200 条;新增的更大编号不会造成重复或页码偏移。 + Page page = BatchExportSupport.page(1); + page.setSearchCount(lastId == null); + LambdaQueryWrapper wrapper = Wrappers.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); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysLogServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysLogServiceImpl.java index 16ee94190..55a5ed7ef 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysLogServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysLogServiceImpl.java @@ -32,12 +32,14 @@ import com.bsd.pjcloud.admin.api.entity.SysLog; import com.bsd.pjcloud.admin.api.vo.PreLogVO; import com.bsd.pjcloud.admin.mapper.SysLogMapper; import com.bsd.pjcloud.admin.service.SysLogService; +import com.bsd.pjcloud.admin.service.log.SysLogSanitizer; import com.bsd.pjcloud.common.data.tenant.TenantBroker; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import com.bsd.pjcloud.common.log.util.LogTypeEnum; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; import java.time.LocalDateTime; import java.util.Comparator; @@ -54,8 +56,11 @@ import java.util.stream.Collectors; * @since 2017-11-20 */ @Service +@RequiredArgsConstructor public class SysLogServiceImpl extends ServiceImpl implements SysLogService { + private final SysLogSanitizer logSanitizer; + /** * 批量插入前端错误日志 * @@ -73,6 +78,7 @@ public class SysLogServiceImpl extends ServiceImpl impleme log.setCreateTime(LocalDateTime.now()); log.setRequestUri(pre.getUrl()); log.setCreateBy(pre.getUser()); + logSanitizer.apply(log); return log; }).collect(Collectors.toList()); return this.saveBatch(sysLogs); @@ -92,7 +98,7 @@ public class SysLogServiceImpl extends ServiceImpl impleme } wrapper.eq(StrUtil.isNotBlank(sysLog.getServiceId()), SysLog::getServiceId, sysLog.getServiceId()); - return baseMapper.selectPage(page, wrapper); + return logSanitizer.copyPage(baseMapper.selectPage(page, wrapper)); } /** @@ -108,6 +114,7 @@ public class SysLogServiceImpl extends ServiceImpl impleme TenantContextHolder.setTenantId(tenantId); SysLog log = new SysLog(); BeanUtils.copyProperties(sysLog, log, "createTime"); + logSanitizer.apply(log); return baseMapper.insert(log); }); return Boolean.TRUE; diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/log/SysLogSanitizer.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/log/SysLogSanitizer.java new file mode 100644 index 000000000..029283d46 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/log/SysLogSanitizer.java @@ -0,0 +1,51 @@ +package com.bsd.pjcloud.admin.service.log; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.entity.SysLog; +import com.bsd.pjcloud.common.core.util.AuditLogSanitizer; +import com.bsd.pjcloud.common.log.config.PjcloudLogProperties; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.ArrayList; + +/** UPMS 写入与历史记录读取的统一边界;不依赖日志采集开关。 */ +@Component +public class SysLogSanitizer { + private final AuditLogSanitizer sanitizer; + + public SysLogSanitizer(@Value("${pjcloud.log.exclude-fields:" + PjcloudLogProperties.DEFAULT_EXCLUDE_FIELDS + "}") + List additionalFields) { + sanitizer = new AuditLogSanitizer(additionalFields); + } + + public SysLog copy(SysLog original) { + SysLog copy = new SysLog(); + BeanUtils.copyProperties(original, copy); + apply(copy); + return copy; + } + + /** 仅对准备入库的新实体或返回副本操作,绝不回写历史记录。 */ + public void apply(SysLog log) { + String uri = log.getRequestUri(); + log.setParams(sanitizer.sanitizeParameters(log.getParams(), uri)); + log.setException(sanitizer.sanitizeText(log.getException(), uri)); + log.setTitle(sanitizer.sanitizeText(log.getTitle(), uri)); + log.setUserAgent(sanitizer.sanitizeText(log.getUserAgent(), uri)); + log.setRequestUri(sanitizer.sanitizeUri(uri)); + } + + public Page copyPage(Page original) { + Page copy = new Page<>(original.getCurrent(), original.getSize(), original.getTotal(), original.searchCount()); + copy.setMaxLimit(original.maxLimit()); + copy.setCountId(original.countId()); + copy.setOptimizeCountSql(original.optimizeCountSql()); + copy.setOptimizeJoinOfCountSql(original.optimizeJoinOfCountSql()); + copy.setOrders(new ArrayList<>(original.orders())); + copy.setRecords(original.getRecords().stream().map(this::copy).toList()); + return copy; + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/utils/excel/SysLogExportStyleHandler.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/utils/excel/SysLogExportStyleHandler.java new file mode 100644 index 000000000..19de5dcdc --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/utils/excel/SysLogExportStyleHandler.java @@ -0,0 +1,152 @@ +package com.bsd.pjcloud.admin.utils.excel; + +import com.alibaba.excel.write.handler.context.CellWriteHandlerContext; +import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; +import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder; +import com.bsd.pjcloud.common.excel.style.ExcelExportStyleHandler; +import com.bsd.pjcloud.common.excel.style.ExcelStyleTokens; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddress; +import org.springframework.beans.BeanUtils; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** 日志报告布局。样式按种类复用,行高仅检查当前写入的单元格,不扫描全部日志。 */ +public class SysLogExportStyleHandler extends ExcelExportStyleHandler { + public static final int HEADER_ROW = 3; + public static final int DATA_START_ROW = HEADER_ROW + 1; + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Map COLUMN_WIDTHS = Map.ofEntries( + Map.entry("createTime", 32D), Map.entry("logType", 12D), Map.entry("title", 28D), + Map.entry("createBy", 18D), Map.entry("serviceId", 28D), Map.entry("method", 12D), + Map.entry("requestUri", 42D), Map.entry("time", 16D), Map.entry("remoteAddr", 42D), + Map.entry("params", 64D), Map.entry("exception", 64D), Map.entry("userAgent", 48D), + Map.entry("id", 32D)); + private static final Set WRAPPED_FIELDS = Set.of("title", "createBy", "serviceId", "requestUri", + "remoteAddr", "params", "exception", "userAgent"); + private static final Set CENTERED_FIELDS = Set.of("logType", "method"); + + private final String period; + private final String fontName; + private final Map bodyStyles = new HashMap<>(); + + public SysLogExportStyleHandler(ExcelStyleTokens source, LocalDateTime[] range) { + super(logTokens(source), COLUMN_WIDTHS); + period = "时间范围:" + range[0].format(TIME_FORMAT) + " 至 " + range[1].format(TIME_FORMAT); + fontName = source.getBody().getFontName(); + } + + /** 只调整本次日志报告,不修改全局配置或其他业务的导出。 */ + private static ExcelStyleTokens logTokens(ExcelStyleTokens source) { + ExcelStyleTokens tokens = new ExcelStyleTokens(); + BeanUtils.copyProperties(source, tokens); + var header = new ExcelStyleTokens.CellTokens(); + var body = new ExcelStyleTokens.CellTokens(); + BeanUtils.copyProperties(source.getHeader(), header); + BeanUtils.copyProperties(source.getBody(), body); + header.setRowHeight(32); + header.setWrapText(false); + body.setRowHeight(32); + body.setWrapText(true); + tokens.setHeader(header); + tokens.setBody(body); + return tokens; + } + + @Override + public void afterSheetCreate(WriteWorkbookHolder workbook, WriteSheetHolder holder) { + super.afterSheetCreate(workbook, holder); + Sheet sheet = holder.getSheet(); + sheet.setZoom(90); + Cell title = sheet.createRow(0).createCell(0); + title.setCellValue("操作日志"); + title.getRow().setHeightInPoints(32); + title.setCellStyle(textStyle(workbook.getWorkbook(), (short) 16, true, IndexedColors.DARK_BLUE)); + Cell scope = sheet.createRow(1).createCell(0); + scope.setCellValue(period); + scope.getRow().setHeightInPoints(22); + scope.setCellStyle(textStyle(workbook.getWorkbook(), (short) 10, false, IndexedColors.GREY_50_PERCENT)); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, COLUMN_WIDTHS.size() - 1)); + sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, COLUMN_WIDTHS.size() - 1)); + sheet.createRow(2).setHeightInPoints(8); + } + + private CellStyle textStyle(Workbook workbook, short size, boolean bold, IndexedColors color) { + Font font = workbook.createFont(); + font.setFontName(fontName); + font.setFontHeightInPoints(size); + font.setBold(bold); + font.setColor(color.getIndex()); + CellStyle style = workbook.createCellStyle(); + style.setFont(font); + style.setAlignment(HorizontalAlignment.CENTER); + style.setVerticalAlignment(VerticalAlignment.CENTER); + return style; + } + + @Override + public void afterCellDispose(CellWriteHandlerContext context) { + super.afterCellDispose(context); + if (!Boolean.FALSE.equals(context.getHead()) || context.getHeadData() == null) return; + Cell cell = context.getCell(); + String field = context.getHeadData().getFieldName(); + boolean wrap = WRAPPED_FIELDS.contains(field); + HorizontalAlignment alignment = "time".equals(field) ? HorizontalAlignment.RIGHT + : CENTERED_FIELDS.contains(field) ? HorizontalAlignment.CENTER : HorizontalAlignment.LEFT; + String numberFormat = "createTime".equals(field) ? "yyyy-mm-dd hh:mm:ss" : "id".equals(field) ? "@" : null; + StyleKey key = new StyleKey(cell.getCellStyle().getIndex(), alignment, wrap, numberFormat); + cell.setCellStyle(bodyStyles.computeIfAbsent(key, ignored -> { + CellStyle style = cell.getSheet().getWorkbook().createCellStyle(); + style.cloneStyleFrom(cell.getCellStyle()); + style.setAlignment(alignment); + style.setVerticalAlignment(VerticalAlignment.TOP); + style.setWrapText(wrap); + if (numberFormat != null) { + style.setDataFormat(cell.getSheet().getWorkbook().createDataFormat().getFormat(numberFormat)); + } + return style; + })); + if (wrap && cell.getCellType() == CellType.STRING) { + int lines = visibleLines(cell.getStringCellValue(), cell.getSheet().getColumnWidth(cell.getColumnIndex()) / 256D); + cell.getRow().setHeightInPoints(Math.max(cell.getRow().getHeightInPoints(), Math.min(108, 16 * lines + 12))); + } + } + + /** 为换行内容增加适量行高,极长原文保留在单元格中供展开查看。 */ + private int visibleLines(String value, double width) { + int lines = 1; + double used = 0; + double capacity = Math.max(8, width - 3); + for (int index = 0; index < value.length() && lines < 6; index++) { + char character = value.charAt(index); + if (character == '\n') { + lines++; + used = 0; + } else if (character != '\r') { + used += character > 255 ? 2 : 1; + if (used > capacity) { + lines++; + used = character > 255 ? 2 : 1; + } + } + } + return lines; + } + + @Override + public void afterWorkbookDispose(WriteWorkbookHolder holder) { + try { + super.afterWorkbookDispose(holder); + // 横向滚动时保留时间和类型,纵向滚动时保留报告上下文及表头。 + holder.getWorkbook().getSheetAt(0).createFreezePane(2, DATA_START_ROW); + } finally { + bodyStyles.clear(); + } + } + + private record StyleKey(short baseStyle, HorizontalAlignment alignment, boolean wrap, String numberFormat) {} +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysDeptMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysDeptMapper.xml index 26f0f5838..733e29e3d 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysDeptMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysDeptMapper.xml @@ -92,4 +92,18 @@ + + diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml index bae556761..fea3fa928 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml @@ -319,7 +319,7 @@ - +