From bbde6cc87f57c171756cf42b3122e48f471df2c3 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 29 Oct 2026 14:28:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E5=9B=BE=E7=89=87=E8=AE=BF=E9=97=AE=20fix:?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 +- docs/file-access/README.md | 129 +++++++++ docs/file-access/config.example.yml | 32 +++ docs/file-access/verification.md | 94 +++++++ .../app/controller/AppUserController.java | 1 + .../core/config/FileAccessConfiguration.java | 16 ++ .../core/file/FileAccessProperties.java | 80 ++++++ .../common/core/file/FileAccessSignature.java | 38 +++ .../common/core/file/FileAccessSigner.java | 89 +++++++ ...ot.autoconfigure.AutoConfiguration.imports | 2 +- .../data/tenant/TenantContextHolder.java | 5 + pjcloud-common/pjcloud-common-excel/README.md | 6 +- .../task/service/impl/RemoteServiceImpl.java | 4 +- .../impl/appmodule/AppVersionServiceImpl.java | 16 +- .../mapper/appmodule/AppVersionMapper.xml | 3 + .../service/AppVersionFileAccessTest.java | 45 ++++ .../config/FileAccessFeignConfiguration.java | 29 +++ .../pjcloud/admin/api/dto/DeptExportDTO.java | 14 + .../bsd/pjcloud/admin/api/dto/SysLogDTO.java | 2 + .../admin/api/dto/SysLogExportDTO.java | 16 ++ .../api/feign/RemoteClientDetailsService.java | 14 +- .../admin/api/feign/RemoteDeptService.java | 6 - .../admin/api/feign/RemoteFileService.java | 14 +- pjcloud-upms/pjcloud-upms-biz/pom.xml | 5 + .../controller/SysAuditLogController.java | 114 ++++---- .../admin/controller/SysClientController.java | 219 ++++++++-------- .../admin/controller/SysDeptController.java | 34 +-- .../admin/controller/SysDictController.java | 11 +- .../admin/controller/SysFileController.java | 20 +- .../admin/controller/SysLogController.java | 24 +- .../admin/controller/SysUserController.java | 7 - .../pjcloud/admin/mapper/SysDeptMapper.java | 10 +- .../pjcloud/admin/service/SysDeptService.java | 2 - .../admin/service/file/FileAccessGuard.java | 125 +++++++++ .../impl/SysDeptBatchExportService.java | 106 ++++++++ .../service/impl/SysDeptServiceImpl.java | 23 -- .../service/impl/SysFileServiceImpl.java | 36 ++- .../impl/SysLogBatchExportService.java | 155 +++++++++++ .../main/resources/mapper/SysDeptMapper.xml | 14 + .../main/resources/mapper/SysUserMapper.xml | 5 +- .../admin/service/file/FileAccessTest.java | 210 +++++++++++++++ .../admin/service/file/FileReadTest.java | 53 ++++ .../service/impl/SysDeptBatchExportTest.java | 186 +++++++++++++ .../service/impl/SysDeptExportSqlTest.java | 170 ++++++++++++ .../service/impl/SysLogBatchExportTest.java | 244 ++++++++++++++++++ .../impl/SysLogExportControllerTest.java | 73 ++++++ .../service/impl/SysUserExportQueryTest.java | 84 ++++++ .../service/impl/SysUserExportStyleTest.java | 20 +- .../test/resources/file-access-vectors.json | 29 +++ .../controller/mch/MchManageController.java | 9 +- .../WalletRechargeInvoiceController.java | 18 +- .../WalletRechargeInvoiceServiceImpl.java | 5 +- .../impl/thrid/WalletThirdZyServiceImpl.java | 4 +- scripts/generate-file-access-config.py | 144 +++++++++++ .../tests/test_generate_file_access_config.py | 91 +++++++ test-vectors/file-access.json | 29 +++ 56 files changed, 2671 insertions(+), 271 deletions(-) create mode 100644 docs/file-access/README.md create mode 100644 docs/file-access/config.example.yml create mode 100644 docs/file-access/verification.md create mode 100644 pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/config/FileAccessConfiguration.java create mode 100644 pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java create mode 100644 pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessSignature.java create mode 100644 pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessSigner.java create mode 100644 pjcloud-integrated/pjcloud-integrated-biz/src/test/java/com/bsd/pjcloud/integrated/service/AppVersionFileAccessTest.java create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/config/FileAccessFeignConfiguration.java create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/DeptExportDTO.java create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysLogExportDTO.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptBatchExportService.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysLogBatchExportService.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessTest.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileReadTest.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysDeptBatchExportTest.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysDeptExportSqlTest.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysLogBatchExportTest.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysLogExportControllerTest.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserExportQueryTest.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/test/resources/file-access-vectors.json create mode 100644 scripts/generate-file-access-config.py create mode 100644 scripts/tests/test_generate_file_access_config.py create mode 100644 test-vectors/file-access.json 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..730092889 --- /dev/null +++ b/docs/file-access/README.md @@ -0,0 +1,129 @@ +# 文件访问签名接入与发布 + +本次保护 `/admin/sys-file/oss/file` 的全部文件。默认 `observe` 只记录签名校验结果;只有加载完整配置并切到 `enforce` 后,普通裸链接才会被拒绝。旧 Dart 图片兼容是独立开关。客户端密钥可提取、UA 可伪造、有效接口地址仍可被模拟请求使用,这是一层应用级校验,不是用户或租户文件授权。 + +## 配置准备 + +服务端配置见 `config.example.yml`。密钥为至少 32 字节随机数据的 Base64 字符串,HMAC 使用 **Base64 解码后的字节**。不要复用 OAuth 客户端信息或密码加密密钥。 + +在 `pj_service_cloud` 执行以下命令可为并列的五个项目生成匹配的本地配置: + +```bash +python3 scripts/generate-file-access-config.py \ + --public-base-url https://www.pjzhhq.cn/api \ + --allowed-origin https://stgl.pj.gov.cn +``` + +按用户最终要求,所有环境共用一套密钥配置,`admin/h5/mini/flutter/service/package` 各自保留固定密钥;开发、测试、生产不再另建密钥组。只有公网基础地址等部署参数随目标环境填写。脚本统一读取 `.file-access/keys.json`,重复执行不会更换密钥;旧命令的 `--environment` 参数仍接受,但不再切换密钥。 + +迁移时沿用原生产密钥组,避免已构建客户端失效。脚本只更新受管理的文件访问配置字段,保留其他业务环境变量;移除旧 Vite 模式文件中的文件访问字段,避免其覆盖公共配置。旧文件备份位于 Java 项目的 `.file-access/migration-backup/`,不参与构建。当前活动配置均被 Git 忽略: + +首次生成只做一次,其他构建机器和 CI 同步这套现有配置。密钥统一后无需随部署环境重新生成。 + +| 项目 | 本地文件 | 使用方式 | +| --- | --- | --- | +| Java | `.file-access/config.yml` | Nacos 共享配置或 Spring 外部配置 | +| 后台 | `.env.local` | 所有 Vite 构建模式共用 | +| H5 和小程序 | 各项目的 `.env.local` | 所有 Vite 构建模式共用 | +| Flutter | `.file-access/config.json` | 所有构建使用 `--dart-define-from-file=.file-access/config.json` | + +前端的配置项为 `VITE_FILE_ACCESS_APP_ID`、`VITE_FILE_ACCESS_KEY_ID`、`VITE_FILE_ACCESS_KEY`、`VITE_FILE_ACCESS_ALLOWED_ORIGINS`。Flutter 去掉 `VITE_` 前缀。Java 的 `clients` 包含 `admin/h5/mini/flutter/service/package`;`service` 和 `package` 密钥只放服务端。 + +`public-base-url` 是外部可访问的网关基础地址,可以带 `/api`,不含 `/admin/sys-file/oss/file`。现有的图片/API 基础地址继续由各端原有配置决定;`allowed-origins` / `ALLOWED_ORIGINS` 只列确实属于本系统的历史域名。上线前必须逐环境核对域名、基础路径、appId、kid 和密钥。相对 `/api/admin/sys-file/oss/file` 按当前文件基础地址解析,绝对历史地址保留其原有域名和部署路径。 + +**Java 不会自动读取仓库里的 `.file-access`。** 本地运行可以设置: + +```bash +export SPRING_CONFIG_ADDITIONAL_LOCATION=file:/absolute/path/pj_service_cloud/.file-access/config.yml +``` + +通过现有 Nacos 配置体系加载这套 `file-access` 配置,或把外部配置挂载到各服务并设置上述变量。实际部署路径与公网基础地址应对应目标机器。首次生成默认为 `observe`,已有配置保留当前模式;显式 `--mode` 可更新本地生成配置的模式。先检查配置源优先级;`config.example.yml` 中的环境变量占位符不能代替实际密钥。严格模式缺少任一客户端的有效活动密钥或公网基础地址会启动失败;运行中错误配置会返回 500,不会降级匿名访问。即使在观察模式,版本签发和服务间读取也需要配置 `package/service` 密钥。 + +## 协议 + +请求查询参数为 `appId`、`kid`、`ts`、`sign`、`fileName`。签名原文按 LF 连接,无末尾换行: + +```text +v1 +oss-file-read +appId +kid +ts +Base64URL无填充编码的UTF-8文件名 +``` + +`ts` 是 Unix 秒;`sign` 是 HMAC-SHA256 的 64 位小写十六进制。`fileName` 只 URL 解码一次,始终放在最后。后端生成的 APK URL 因此仍以 `.apk` 结尾,兼容旧终端的扩展名解析。 + +默认普通签名有效 1800 秒,`package` 有效 86400 秒,允许签发时间领先最多 300 秒。`GET /admin/sys-file/oss/access-info` 匿名返回 `R.data.serverTime`(**毫秒**)和 `ttlSeconds`,不包含密钥。各端校准时差并在签名剩余不足 5 分钟时续期;校时失败保留上次时差,不能退回裸链接。传输 URL 缓存在内存里,普通渲染不会每次产生新签名。 + +重复关键参数、缺少字段、非规范时间戳或非规范签名为 400;未知客户端/密钥、错误签名、过期、时钟越界为 403;文件不存在为 404,数据库或存储读取失败为 500。GET 和 HEAD 共用校验。普通登录 Token 不会绕过校验。文件响应最多私有缓存 5 分钟且不超过剩余签名寿命;错误响应禁止缓存。已经输出部分字节后的网络/存储中断无法可靠改写 HTTP 状态,应按中断响应处理。 + +`block-browser-navigation` 默认为 `true`。严格模式下,浏览器携带 `Sec-Fetch-Mode: navigate` 或 `Sec-Fetch-Dest: document/iframe/frame` 的文件请求返回 403,即使签名正确也不放行;覆盖地址栏粘贴、新窗口打开和直接嵌入文件页面。图片、音视频以及 fetch/XHR 下载继续校验签名后访问。响应的 `Vary` 包含 `Sec-Fetch-Mode` 和 `Sec-Fetch-Dest`,防止浏览器把图片缓存复用为文件页面。观察模式仅记录 `browser_navigation`,不拦截。 + +后台会截获本系统文件 `` 链接,通过已有 Blob 下载工具处理;H5/小程序富文本文件链接改走图片预览或下载后打开本地文件。复制出来的接口地址不再承诺可直接在浏览器打开。Flutter 原生下载、Feign 等没有浏览器 Fetch Metadata 的请求仍执行签名/旧 Dart 规则;不支持这些请求头的旧浏览器同样不能靠本规则识别。这项规则用于限制普通浏览器导航,不能防止脚本携带有效签名并模拟请求,也不能阻止已经获准显示的图片被保存。 + +## 入口与数据约束 + +| 部分 | 接入位置与行为 | +| --- | --- | +| Java 文件入口 | `FileAccessGuard`,`SysFileController`,`SysFileServiceImpl`;租户跳过只作用于文件查询并恢复原状态 | +| Java 内部读取 | `RemoteFileService` 保留 `getFile` 调用,通过编码桥接和专属 Feign 拦截器签名,保留文件名中的字面 `%xx`;钱包、硬件导出/日志/取证通过相同签名读取;票据 URLConnection 单独适配 | +| APK | 终端 `/app-version/ad/page` 和 `/app/last-version` 返回对象使用 `package` 签名,后台列表/编辑和数据库仍为原始值;独立静态 APK 地址保持不变 | +| 后台 | `src/utils/file-access*.ts`;`FileImage` 保留已加载画面,未加载的懒图片继续续期,打开/切换预览时取有效签名;失败重试仅刷新当前文件,不清空全局签名缓存;上传回显、素材库、头像、视频、版本复制和下载均已接入;KKFileView 在编码之前签名源地址 | +| 后台编辑器 | 只更新编辑器 DOM 的资源属性,不替换整篇文档;输出 HTML 剥离临时签名,续期不进入编辑历史 | +| H5 和小程序 | `src/sheep/utils/file-access*.ts`、`filePath/parseHtml`、共享预览下载工具及各页面资源属性;公共算法不依赖 DOM/window;富文本图片样式保留 | +| Flutter | `FileAccess`、`AppNetworkTool`、`SignedNetworkImage`;主副屏独立初始化,缓存键用规范化原始地址,传输 URL 用签名 | +| Flutter APK 重试 | 原生下载收到 403 时重新查询相同 ID、版本码、平台和应用的已发布版本,最多重试一次,不能换成另一个版本继续安装 | + +业务对象、表单、上传返回值、SQLite 人脸 `imagePath`、失败记录和 `referenceImageUrl` 继续保留原始引用。本次没有数据库迁移、SQLite 升级或清空人脸库。新业务展示统一调用文件工具,不要在 API 响应拦截器里递归改写业务字符串。 + +第三方 URL、静态资源、`data:`、`blob:`、本地临时文件不加本协议签名。复制访问链接是临时访问地址;编辑、存储文件引用使用原始值。 + +旧终端兼容只接受完整 `Dart/<版本> (dart:io)` UA,且扩展名在 jpg/jpeg/png/gif/webp/bmp/svg/ico 内。完全没有签名参数才可走兼容分支;错误或过期签名不会回退。APK、日志、文档不凭 Dart UA 放行。 + +## 发布和回滚 + +1. 在配置中心准备匹配的环境配置,保持 `mode: observe` 和 `legacy-dart.enabled: true`。发布 UPMS、integrated,以及包含文件调用的 wallet、hardware、canteen 等服务和统一公共依赖。只更新 UPMS 会遗漏内部读取。 +2. 发布后台、H5、小程序,并提供携带生产配置的新 Flutter 包。小程序保留原文件域名,真机检查 request/downloadFile 域名配置、图片预览和打开文档。 +3. 至少观察一个完整业务日,确认登录前 Logo/背景、人员照片、商品/菜品、富文本、附件下载、导出、票据和旧设备更新链路。旧设备必须实测图片 UA 和 Android 原生 APK 下载。 +4. 通过验收后设置 `mode: enforce`。按当前配置中心机制刷新或重启实例,逐个确认配置已生效;旧网页需刷新,小程序需加载新版本。 +5. 根据设备版本台账确认覆盖后再关 `legacy-dart.enabled`。没有自动失效日,短期没有旧请求不能证明设备已全部升级。 +6. 遗漏影响业务时切回 `observe` 并保留诊断;这会重新允许普通未签名请求。无需数据库回滚,也无需四端同时回退。 + +代理/CDN 必须把文件路径送到后端校验,保留完整查询参数,不得忽略查询参数缓存文件、强制公共缓存或通过其他公开存储入口绕过该接口。校时接口也必须匿名可达。不要在访问日志中记录完整签名 URL、密钥或登录 Token;可按路径、来源类别和结果计数。 + +监控 `file.access.requests`(Prometheus 通常呈现为 `file_access_requests_total`),标签 `outcome/app/mode`。结果包括 `signed`、`missing_signature`、`malformed_signature`、`invalid_signature`、`expired`、`clock_skew`、`legacy_dart`、`unknown_client`、`unknown_key`、`configuration_error` 等。文件查询/存储失败另计 `file.access.read.failures`。拒绝/兼容日志按结果采样,计数器保留完整次数。 + +轮换时先在服务端增加新 kid,同时保留旧 kid,再更新客户端构建;服务端主动签发使用 active-kid。Flutter 的旧 kid 需等设备覆盖后才移除。不要用重新运行生成脚本代替轮换流程。 + +## 本地验证命令 + +三个前端分别执行: + +```bash +node --test tests/file-access.test.cjs +``` + +后台执行 `pnpm build:prod`,H5 执行 `pnpm build:h5`,小程序执行 `pnpm build:mp-weixin`。跨语言固定向量在各项目 `test-vectors/file-access.json`,其中的密钥是公开测试数据,不能用于部署。 + +Java 定向回归: + +```bash +mvn -pl pjcloud-upms/pjcloud-upms-biz,pjcloud-integrated/pjcloud-integrated-biz,pjcloud-wallet/pjcloud-wallet-biz,pjcloud-hardware/pjcloud-hardware-biz,pjcloud-canteen/pjcloud-canteen-biz \ + -am test \ + -Dtest=FileAccessTest,FileReadTest,AppVersionFileAccessTest,WalletManageBatchExportTest,WalletLedgerBatchExportTest,HardBatchExportFileTest,HardBatchExportQueryTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +Flutter 使用项目 FVM 3.27.4: + +```bash +fvm flutter test --no-pub --dart-define-from-file=.file-access/config.json \ + test/file_access_test.dart test/package_download_test.dart \ + test/face_sync_plan_test.dart test/face_sync_pipeline_test.dart \ + test/face_sync_failure_store_test.dart test/face_readiness_cache_test.dart \ + test/customer_display_balance_test.dart test/customer_display_balance_widget_test.dart +fvm flutter build apk --release --no-pub --dart-define-from-file=.file-access/config.json +``` + +验收结果与待现场验证项见 `verification.md`。不要把本地构建成功或源码中的 Dart UA 推断视为旧安装包验收。 diff --git a/docs/file-access/config.example.yml b/docs/file-access/config.example.yml new file mode 100644 index 000000000..5cb5d1fed --- /dev/null +++ b/docs/file-access/config.example.yml @@ -0,0 +1,32 @@ +# Load through Nacos shared config or Spring additional-location. No usable keys are committed. +file-access: + mode: observe + block-browser-navigation: true + ttl-seconds: 1800 + package-ttl-seconds: 86400 + future-skew-seconds: 300 + public-base-url: https://files.example.com/api + allowed-origins: + - https://legacy-files.example.com + legacy-dart: + enabled: true + extensions: [jpg, jpeg, png, gif, webp, bmp, svg, ico] + clients: + admin: + active-kid: v1 + keys: {v1: '${FILE_ACCESS_ADMIN_V1:}'} + h5: + active-kid: v1 + keys: {v1: '${FILE_ACCESS_H5_V1:}'} + mini: + active-kid: v1 + keys: {v1: '${FILE_ACCESS_MINI_V1:}'} + flutter: + active-kid: v1 + keys: {v1: '${FILE_ACCESS_FLUTTER_V1:}'} + service: + active-kid: v1 + keys: {v1: '${FILE_ACCESS_SERVICE_V1:}'} + package: + active-kid: v1 + keys: {v1: '${FILE_ACCESS_PACKAGE_V1:}'} diff --git a/docs/file-access/verification.md b/docs/file-access/verification.md new file mode 100644 index 000000000..af84ef0d7 --- /dev/null +++ b/docs/file-access/verification.md @@ -0,0 +1,94 @@ +# 五端文件签名验证记录 + +验证日期:2026-10-29。范围为本地五个项目的实现、定向回归、构建和已登录后台页面检查。未发布服务、修改 Nacos、开启生产拦截、更新真实设备、执行数据库迁移或提交 Git。 + +## 已完成 + +| 验证 | 结果 | +| --- | --- | +| Java 请求级与业务回归 | 41 项通过,0 失败;覆盖 UPMS 文件校验/读取、版本地址、钱包和硬件导出,同时编译相关公共模块及 canteen | +| 后台自动测试 | 14 项通过,包含文件协议与已有部门导出测试 | +| H5 文件自动测试 | 14 项通过 | +| 小程序文件自动测试 | 14 项通过 | +| 跨语言协议 | Java、三端 TypeScript、Dart 使用相同公开向量;校验 UTF-8 文件名、秒级时间戳、Base64URL 原文和 HMAC 结果 | +| URL 与运行时 | 覆盖中文、空格、加号、百分号编码、历史域名、相对/绝对地址、已有 `/api` 前缀、重复签名、第三方地址;前端测试执行实际运行时包装器,H5/小程序测试执行富文本预览组件的 JS 方法;Java 使用真实 SpringMvcContract/Feign 客户端与内存传输验证文件名字面 `%2F/%2B`、重复拦截和参数顺序 | +| 后台生产构建 | `pnpm build:prod` 通过 | +| H5 构建 | `pnpm build:h5` 通过 | +| 微信小程序构建 | `pnpm build:mp-weixin` 通过 | +| Flutter 定向回归 | 56 项通过;含签名、安装包一次重试、人脸同步计划/流水线/失败记录/缓存、主副屏余额回归 | +| Android Release | FVM Flutter 3.27.4 构建通过,使用 `.file-access/production.json`;APK 约 101.9 MB | +| 新增后台 TypeScript 工具 | 独立严格类型检查通过 | +| 配置生成器 | 在临时目录重复运行,确认幂等、保留已有文件;开发/测试/生产配置和各客户端密钥相互独立 | +| 密钥与原有变更 | 扫描五个项目全部已跟踪及未忽略候选文件,未发现生成密钥泄漏;后台原有补丁可反向校验,Java 原有补丁除追加忽略规则产生的上下文差异外可反向校验 | +| Diff 检查 | 保留原有 CRLF 文件格式;使用 `git -c core.whitespace=cr-at-eol diff --check` 通过 | + +APK 位置(相对本仓库):`../pj_supermarket_cloud/build/app/outputs/flutter-apk/app-release.apk`。本次构建尚未在真实终端安装验证;发布前须核对包内环境与服务端配置一致。 + +Java 定向测试及各端构建命令见 [接入与发布说明](README.md)。本机详细日志保存在 `/tmp/pj-file-access-work/`,属于临时验证资料,不随仓库发布。 + +## 静态检查与既有问题 + +使用任务开始前保存的补丁还原临时代码基线,与当前代码运行同一检查后比较: + +| 项目 | 当前结果与基线比较 | +| --- | --- | +| 后台 `vue-tsc` | 6 条既有解析错误,均在 `src/views/admin/system/tenant/index.vue:83`;与基线完全一致 | +| H5 `vue-tsc` | 4 条既有解析错误,在 `src/pages/tabbar/~individualOrder.vue:78-79`;与基线完全一致 | +| 小程序 `vue-tsc` | 17 条既有 TS6504(JS Vue 来源与 allowJs 配置);规范化临时目录路径后与基线完全一致 | +| Flutter `analyze --no-pub` | 69 条提示/警告;忽略因新增代码产生的行号偏移后,诊断集合与基线完全一致,无新增项 | + +因此不能把本次结果表述为“全项目类型检查通过”。H5/小程序构建仍有 TM UI 类型导出和循环依赖等构建提示;构建已成功,未在本次跨端文件改造中整理无关依赖。 + +## 已登录后台检查 + +使用用户提供的 `http://localhost:8888/` 已登录会话,检查文件管理页: + +- 本系统有效图片的缩略图与放大预览加载成功,资源请求带 `appId/kid/ts/sign`,`fileName` 位于最后。 +- 实测发现并修复前端 `/api` 前缀被重复保留的问题:当前开发图片服务器不带 `/api`,相对旧路径应转换为 `/admin/sys-file/oss/file`。配置含 `/api` 的部署仍保留一次前缀。 +- 旧头像的一条原始文件链接返回 HTTP 200、内容长度为 0,属于当前运行后端/存储的既有异常。新 Java 读取代码已覆盖不存在和存储异常状态,但尚未发布,不能声称现场问题已修复。 +- 当前后端匿名 `/admin/sys-file/oss/access-info` 返回 424 鉴权错误,说明运行环境尚未具备本次匿名校时接口;签名客户端会保留本地时间/最近偏移,不降级匿名读取。 +- 点击素材下载后未出现页面错误,但浏览器下载事件未在等待窗口内返回。浏览器工具自动安全检查拒绝打开 `chrome://downloads/`(仅允许 HTTP/HTTPS),未采用其他方式绕过此限制,**下载文件实际落盘未确认**。 + +当前运行后端仍接受裸文件链接。上述浏览器结果证明前端签名与展示链路可用,不代表新后端严格校验已在线生效。 + +## 发布前仍需完成 + +1. 核对每个环境的公网基础地址、历史域名、密钥和活动 kid;Java 不会自动加载 `.file-access/*.yml`,须通过 Nacos 或 `SPRING_CONFIG_ADDITIONAL_LOCATION` 加载。先以 `observe` 发布 UPMS、integrated 和所有实际调用文件服务的模块。 +2. 用发布后的后端确认匿名校时可达,执行 GET/HEAD、错误参数、错误/过期签名、旧 Dart 兼容、404/500、私有缓存及服务间读取联调。 +3. 在真实旧 Flutter 安装包上确认图片请求 UA、图片显示及 Android 原生 APK 下载。新版包确认主副屏、缓存命中、人脸重复同步/离线恢复、取证、打印、手动/自动更新;不能用本地源码或单元测试替代设备验收。 +4. 微信真机验证登录前 Logo/背景、长时间停留和恢复前台、图片预览、文件下载及打开文档;核对微信文件域名配置。后台与 H5 补充对应实际业务操作验收。 +5. 至少观察一个完整业务日,确认已知入口无遗漏后再切 `enforce`;根据设备版本台账确认升级覆盖后再关闭旧 Dart 图片兼容。 + +当前默认保持 `observe` 与旧 Dart 图片兼容开启。回滚方法是恢复 `observe` 并保留诊断日志;业务引用和人脸记录未持久化临时签名,无需数据库回滚。 + +## 后台图片闪烁修复补充 + +2026-10-29 修复后台单张图片失败重试清空全局签名缓存的问题。重试只刷新当前文件,同一文件的并发重试合并;普通校时保留仍有效的签名。新增公共 `FileImage`,已加载图片在分钟检查、签名续期和恢复前台时保持当前画面;未加载的懒图片继续更新有效地址,放大预览在打开或切换图片时取有效签名。业务引用改变时正常加载新图。 + +- 后台定向回归 19 项通过,包含真实组件 setup、模板与 Vue 生命周期测试;模拟停留超过 30 分钟、恢复前台、失败重试、预览和懒加载。 +- 新组件与文件工具的定向 `vue-tsc` 检查通过;后台生产构建通过。 +- 已登录商户页实测:原先已加载的 24 张图片跨分钟检查后地址不变,其余慢图片随后加载完成,共 30 张图片成功、无加载占位;放大预览成功且请求带签名。 + +## 浏览器直接打开文件地址限制补充 + +2026-10-29 新增默认开启的 `block-browser-navigation`。严格模式下,携带浏览器页面导航 Fetch Metadata 的文件请求,即使签名有效也返回 403;页面图片、音视频、fetch/XHR 下载和无浏览器 Metadata 的原生/服务请求继续按原签名规则处理。成功响应 `Vary` 区分导航与子资源请求;拒绝响应 `no-store`。观察模式仅记录 `browser_navigation`。 + +- Java 文件访问/读取请求级测试共 12 项通过,覆盖签名正确的导航、GET/HEAD 在读取存储前拒绝、图片与下载放行、原生包下载、Dart UA 不绕过导航限制、开关和观察模式。 +- 后台 20 项、H5 16 项、小程序 16 项定向测试通过。覆盖文件链接的页面内下载、H5 富文本与普通链接拦截、图片预览、本地文档打开及第三方链接保持原行为。 +- 后台生产、H5、微信小程序构建通过;后台文件组件/工具定向类型检查通过。 +- 配置生成器在临时目录验证:新配置含 `block-browser-navigation: true`,默认仍为 `observe`,没有修改实际运行环境配置。 + +这项补充尚未部署到文件服务器,不能把本地请求级测试表述为该公网地址已返回 403。上线时代理须保留 `Sec-Fetch-Mode/Dest` 和响应 `Vary`;用新签名或无旧缓存的窗口验证。后端无法收回浏览器已经取得的文件内容。旧浏览器可能不提供 Fetch Metadata,脚本也能模拟请求,因此这项规则不等于逐用户授权或防止已显示图片被保存。 + +## 最终调整:统一一套密钥与严格模式联调 + +用户明确要求所有环境使用同一套密钥,取代本文前面记录的开发/测试/生产独立密钥方案。沿用已配置的原生产密钥组,未生成新密钥;各 appId 仍使用各自固定密钥,service/package 密钥仍只存在服务端。三个前端统一使用 `.env.local`,Java 使用 `.file-access/config.yml`,Flutter 使用 `.file-access/config.json`。旧模式文件的签名字段已清理并备份,旧命令 `--environment` 不再切换密钥。 + +此前严格模式白图的真实原因是:浏览器按开发密钥签名,后端配置的是原生产密钥,实际响应为 `invalid_signature`。本次已通过本地 Nacos REST API 修正 `application-dev.yml`,保留 `enforce`、浏览器导航拦截和旧 Dart 兼容,公网基础地址对齐测试网关。Nacos 完整原配置保存在本机私有临时备份,发布时使用 MD5 比较避免覆盖并发编辑,生产服务配置未修改。 + +- 使用真实页面中的同一个签名 URL 联调:页面图片请求 200、应用下载请求 200、浏览器导航请求 403,原因为 `browser_navigation`。 +- 实际后台重新加载后,首轮观察为 30 张图片中的 24 张加载成功,其余 6 张仍在慢请求中;随后浏览器自动化连接中断,未把该轮全部图片的最终状态作为已验证结果。签名已与统一密钥注册表一致。 +- 真实 Vite `loadEnv` 验证三个项目共 18 个模式组合,全部使用统一密钥;生成器迁移、重复执行、原密钥保留与错误配置拒绝共 3 项测试通过。 +- 后台生产、H5、微信小程序构建通过;Flutter 使用统一配置执行签名和安装包定向测试,8 项通过。 + +后续构建与配置准备以 README 中的统一路径为准。本文前面提到的分环境配置路径属于历史验证记录,不再作为当前操作指引。 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..f0736eb82 --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java @@ -0,0 +1,80 @@ +package com.bsd.pjcloud.common.core.file; + +import lombok.Data; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.*; + +@Data +@ConfigurationProperties(prefix = "file-access") +public class FileAccessProperties implements InitializingBean { + private String mode = "observe"; + private long ttlSeconds = 1800; + private long packageTtlSeconds = 86400; + private long futureSkewSeconds = 300; + private boolean blockBrowserNavigation = true; + private String publicBaseUrl = ""; + private List allowedOrigins = new ArrayList<>(); + private Map clients = new LinkedHashMap<>(); + private LegacyDart legacyDart = new LegacyDart(); + + @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 (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..d78803aeb --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessSignature.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.common.core.file; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.HexFormat; + +/** Versioned wire protocol shared with the browser and Dart clients. */ +public final class FileAccessSignature { + private FileAccessSignature() { } + + public static String canonical(String fileName, String appId, String kid, long timestamp) { + return String.join("\n", "v1", "oss-file-read", appId, kid, Long.toString(timestamp), + Base64.getUrlEncoder().withoutPadding().encodeToString(fileName.getBytes(StandardCharsets.UTF_8))); + } + + public static String sign(String fileName, String appId, String kid, long timestamp, String base64Key) { + try { + byte[] key = Base64.getDecoder().decode(base64Key); + if (key.length < 32) throw new IllegalArgumentException("File access key must contain at least 32 bytes"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(canonical(fileName, appId, kid, timestamp) + .getBytes(StandardCharsets.UTF_8))); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Cannot calculate file access signature", e); + } + } + + public static boolean matches(String expected, String actual) { + return actual != null && actual.matches("[0-9a-f]{64}") + && MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), actual.getBytes(StandardCharsets.US_ASCII)); + } +} 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..da9760da0 --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessSigner.java @@ -0,0 +1,89 @@ +package com.bsd.pjcloud.common.core.file; + +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.*; + +/** Signs only this installation's file-center URLs, never arbitrary remote URLs. */ +public class FileAccessSigner { + private final FileAccessProperties properties; + private final Clock clock; + + public FileAccessSigner(FileAccessProperties properties) { this(properties, Clock.systemUTC()); } + public FileAccessSigner(FileAccessProperties properties, Clock clock) { this.properties = properties; this.clock = clock; } + + public Map 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 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/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-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..121b57f16 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,12 @@ excel: 列宽优先级:配置中的 `column-widths` > 业务默认列宽 > `column-width`。用户导出已按编号、手机、邮箱、部门、角色和日期等字段分别设置列宽,不扫描全部明细来计算列宽。长文本保持原值,默认正文不换行;需要多行显示时可同时增加 `body.row-height` 并开启 `body.wrap-text`。开启交替行底纹时,偶数数据行使用 `alternate-row-color`,其余数据行使用 `body.background-color`。 -后续导出可显式注册处理器来复用样式(本次不接入其他接口): +其他导出可显式注册处理器来复用样式: ```java builder.registerWriteHandler(new ExcelExportStyleHandler(excelConfig.getExportStyle())); ``` 每次导出创建独立处理器,在提交任务时固定当前 token。处理器只支持 XLSX,缓存工作簿中的字体与单元格样式,条纹按实际行号计算,适用于分批流式写入。现有转换器、单元格类型、数据格式及导出列保持原样。固定格式模板应继续使用自身样式,不自动注册此处理器。 + +日志导出沿用原有列,支持日志类型、创建时间范围、应用标识及原接口的日志编号筛选。每批按编号倒序读取 200 条,仅首批统计总数,后续使用编号游标;新增的更大编号不会挤偏后续批次。旧 GET 接口仍直接返回附件,新 POST `/admin/log/export-async` 返回任务信息,使用 `/admin/async-task/progress` 和命名空间 `admin:export:log` 查询进度。两个接口均要求 `sys_log_export` 权限,超出单工作表行数限制时提示缩小筛选范围。 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..f77f76cca --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/config/FileAccessFeignConfiguration.java @@ -0,0 +1,29 @@ +package com.bsd.pjcloud.admin.api.config; + +import com.bsd.pjcloud.common.core.file.FileAccessSigner; +import feign.RequestInterceptor; +import org.springframework.context.annotation.Bean; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Collection; + +/** Intentionally not component-scanned: applies only to RemoteFileService. */ +public class FileAccessFeignConfiguration { + @Bean + public RequestInterceptor fileAccessRequestInterceptor(FileAccessSigner signer) { + return template -> { + if (!"GET".equals(template.method()) || !"/sys-file/oss/file".equals(template.path())) return; + Collection 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-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/SysClientController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysClientController.java index 2d3d61119..dd776707b 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysClientController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysClientController.java @@ -62,120 +62,127 @@ import java.util.List; @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) public class SysClientController { - private final SysOauthClientDetailsService clientDetailsService; + private final SysOauthClientDetailsService clientDetailsService; - /** - * 通过ID查询 - * @param clientId clientId - * @return SysOauthClientDetails - */ - @GetMapping("/{clientId}") - public R getByClientId(@PathVariable String clientId) { - SysOauthClientDetails details = clientDetailsService - .getOne(Wrappers.lambdaQuery().eq(SysOauthClientDetails::getClientId, clientId)); - String information = details.getAdditionalInformation(); - String captchaFlag = JSONUtil.parseObj(information).getStr(CommonConstants.CAPTCHA_FLAG); - String encFlag = JSONUtil.parseObj(information).getStr(CommonConstants.ENC_FLAG); - String onlineQuantity = JSONUtil.parseObj(information).getStr(CommonConstants.ONLINE_QUANTITY); - SysOauthClientDetailsDTO dto = new SysOauthClientDetailsDTO(); - BeanUtils.copyProperties(details, dto); - dto.setCaptchaFlag(captchaFlag); - dto.setEncFlag(encFlag); - dto.setOnlineQuantity(onlineQuantity); - return R.ok(dto); - } + /** + * 通过ID查询 + * + * @param clientId clientId + * @return SysOauthClientDetails + */ + @GetMapping("/{clientId}") + public R getByClientId(@PathVariable String clientId) { + SysOauthClientDetails details = clientDetailsService + .getOne(Wrappers.lambdaQuery().eq(SysOauthClientDetails::getClientId, clientId)); + String information = details.getAdditionalInformation(); + String captchaFlag = JSONUtil.parseObj(information).getStr(CommonConstants.CAPTCHA_FLAG); + String encFlag = JSONUtil.parseObj(information).getStr(CommonConstants.ENC_FLAG); + String onlineQuantity = JSONUtil.parseObj(information).getStr(CommonConstants.ONLINE_QUANTITY); + SysOauthClientDetailsDTO dto = new SysOauthClientDetailsDTO(); + BeanUtils.copyProperties(details, dto); + dto.setCaptchaFlag(captchaFlag); + dto.setEncFlag(encFlag); + dto.setOnlineQuantity(onlineQuantity); + return R.ok(dto); + } - /** - * 简单分页查询 - * @param page 分页对象 - * @param sysOauthClientDetails 系统终端 - * @return - */ - @GetMapping("/page") - public R getOauthClientDetailsPage(@ParameterObject Page page, - @ParameterObject SysOauthClientDetails sysOauthClientDetails) { - LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() - .like(StrUtil.isNotBlank(sysOauthClientDetails.getClientId()), SysOauthClientDetails::getClientId, - sysOauthClientDetails.getClientId()) - .like(StrUtil.isNotBlank(sysOauthClientDetails.getClientSecret()), SysOauthClientDetails::getClientSecret, - sysOauthClientDetails.getClientSecret()); - return R.ok(clientDetailsService.page(page, wrapper)); - } + /** + * 简单分页查询 + * + * @param page 分页对象 + * @param sysOauthClientDetails 系统终端 + * @return + */ + @GetMapping("/page") + @HasPermission("sys_client_page") + public R getOauthClientDetailsPage(@ParameterObject Page page, @ParameterObject SysOauthClientDetails sysOauthClientDetails) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .like(StrUtil.isNotBlank(sysOauthClientDetails.getClientId()), SysOauthClientDetails::getClientId, + sysOauthClientDetails.getClientId()) + .like(StrUtil.isNotBlank(sysOauthClientDetails.getClientSecret()), SysOauthClientDetails::getClientSecret, + sysOauthClientDetails.getClientSecret()); + return R.ok(clientDetailsService.page(page, wrapper)); + } - /** - * 添加 - * @param clientDetailsDTO 实体 - * @return success/false - */ - @SysLog("添加终端") - @PostMapping - @HasPermission("sys_client_add") - public R add(@Valid @RequestBody SysOauthClientDetailsDTO clientDetailsDTO) { - return R.ok(clientDetailsService.saveClient(clientDetailsDTO)); - } + /** + * 添加 + * + * @param clientDetailsDTO 实体 + * @return success/false + */ + @SysLog("添加终端") + @PostMapping + @HasPermission("sys_client_add") + public R add(@Valid @RequestBody SysOauthClientDetailsDTO clientDetailsDTO) { + return R.ok(clientDetailsService.saveClient(clientDetailsDTO)); + } - /** - * 删除 - * @param ids ID 列表 - * @return success/false - */ - @SysLog("删除终端") - @DeleteMapping - @HasPermission("sys_client_del") - public R removeById(@RequestBody Long[] ids) { - clientDetailsService.removeBatchByIds(CollUtil.toList(ids)); - SpringContextHolder.publishEvent(new ClientDetailsInitRunner.ClientDetailsInitEvent(ids)); - return R.ok(); - } + /** + * 删除 + * + * @param ids ID 列表 + * @return success/false + */ + @SysLog("删除终端") + @DeleteMapping + @HasPermission("sys_client_del") + public R removeById(@RequestBody Long[] ids) { + clientDetailsService.removeBatchByIds(CollUtil.toList(ids)); + SpringContextHolder.publishEvent(new ClientDetailsInitRunner.ClientDetailsInitEvent(ids)); + return R.ok(); + } - /** - * 编辑 - * @param clientDetailsDTO 实体 - * @return success/false - */ - @SysLog("编辑终端") - @PutMapping - @HasPermission("sys_client_edit") - public R update(@Valid @RequestBody SysOauthClientDetailsDTO clientDetailsDTO) { - return R.ok(clientDetailsService.updateClientById(clientDetailsDTO)); - } + /** + * 编辑 + * + * @param clientDetailsDTO 实体 + * @return success/false + */ + @SysLog("编辑终端") + @PutMapping + @HasPermission("sys_client_edit") + public R update(@Valid @RequestBody SysOauthClientDetailsDTO clientDetailsDTO) { + return R.ok(clientDetailsService.updateClientById(clientDetailsDTO)); + } - @Inner(false) - @GetMapping("/getClientDetailsById/{clientId}") - public R getClientDetailsById(@PathVariable String clientId) { - return R.ok(clientDetailsService.getOne( - Wrappers.lambdaQuery().eq(SysOauthClientDetails::getClientId, clientId), false)); - } + @Inner(false) + @GetMapping("/getClientDetailsById/{clientId}") + public R getClientDetailsById(@PathVariable String clientId) { + return R.ok(clientDetailsService.getOne( + Wrappers.lambdaQuery().eq(SysOauthClientDetails::getClientId, clientId), false)); + } - /** - * 查询全部客户端 - * @return - */ - @Inner(false) - @GetMapping("/list") - public R listClients() { - return R.ok(clientDetailsService.list()); - } +// /** +// * 查询全部客户端 +// * @return +// */ +// @Inner(false) +// @GetMapping("/list") +// public R listClients() { +// return R.ok(clientDetailsService.list()); +// } - /** - * 同步缓存字典 - * @return R - */ - @SysLog("同步终端") - @PutMapping("/sync") - public R sync() { - return clientDetailsService.syncClientCache(); - } + /** + * 同步缓存字典 + * + * @return R + */ + @SysLog("同步终端") + @PutMapping("/sync") + public R sync() { + return clientDetailsService.syncClientCache(); + } - /** - * 导出所有客户端 - * @return excel - */ - @ResponseExcel - @SysLog("导出excel") - @GetMapping("/export") - public List export(SysOauthClientDetails sysOauthClientDetails) { - return clientDetailsService.list(Wrappers.query(sysOauthClientDetails)); - } + /** + * 导出所有客户端 + * + * @return excel + */ + @ResponseExcel + @SysLog("导出excel") + @GetMapping("/export") + public List export(SysOauthClientDetails sysOauthClientDetails) { + return clientDetailsService.list(Wrappers.query(sysOauthClientDetails)); + } } 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..3864128e3 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,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import com.bsd.pjcloud.admin.service.file.FileAccessGuard; +import com.bsd.pjcloud.common.core.file.FileAccessProperties; +import java.io.IOException; import lombok.AllArgsConstructor; import lombok.SneakyThrows; import org.springdoc.core.annotations.ParameterObject; @@ -59,6 +63,8 @@ import java.util.Objects; public class SysFileController { private final SysFileService sysFileService; + private final FileAccessGuard fileAccessGuard; + private final FileAccessProperties fileAccessProperties; /* ************************************* start 远程调用 start ************************************* */ @Inner @@ -140,8 +146,18 @@ public class SysFileController { */ @Inner(false) @GetMapping("/oss/file") - public void file(String fileName, HttpServletResponse response) { - sysFileService.getFile(fileName, response); + public void file(String fileName, HttpServletRequest request, HttpServletResponse response) throws IOException { + if (fileAccessGuard.authorize(request, response)) { + sysFileService.getFile(fileName, response); + fileAccessGuard.recordReadResult(response.getStatus()); + } + } + + @Inner(false) + @GetMapping("/oss/access-info") + public R> 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 b5b8d5683..c6231d7ca 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 @@ -367,9 +367,6 @@ public class SysUserController { /** * 导出excel 表格 - * - * @param userDTO 查询条件 - * @return */ @PostMapping("/export") @HasPermission("sys_user_export") @@ -386,10 +383,6 @@ public class SysUserController { /** * 导入用户 - * - * @param excelVOList 用户列表 - * @param bindingResult 错误信息列表 - * @return R */ @PostMapping("/import") @HasPermission("sys_user_export") 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/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..7aec783df 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 @@ -149,19 +149,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) { } + } + /** * 删除文件 * 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..6656b5b36 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysLogBatchExportService.java @@ -0,0 +1,155 @@ +package com.bsd.pjcloud.admin.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.write.metadata.WriteSheet; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.SysLogExportDTO; +import com.bsd.pjcloud.admin.api.entity.SysLog; +import com.bsd.pjcloud.admin.mapper.SysLogMapper; +import com.bsd.pjcloud.admin.service.SysFileService; +import com.bsd.pjcloud.common.asynctask.service.*; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.excel.config.ExcelConfigProperties; +import com.bsd.pjcloud.common.excel.converters.LocalDateTimeStringConverter; +import com.bsd.pjcloud.common.excel.converters.LongStringConverter; +import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; +import com.bsd.pjcloud.common.excel.stream.ExportMultipartFile; +import com.bsd.pjcloud.common.excel.style.ExcelExportStyleHandler; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** 日志按主键游标分批读取,同步和异步导出共用原有列及统一 Excel 样式。 */ +@Service +@RequiredArgsConstructor +public class SysLogBatchExportService { + private static final String FILE_NAME = "操作日志.xlsx"; + private static final Map COLUMN_WIDTHS = Map.ofEntries( + Map.entry("id", 24D), Map.entry("logType", 28D), Map.entry("title", 32D), + Map.entry("createBy", 20D), Map.entry("createTime", 24D), Map.entry("remoteAddr", 20D), + Map.entry("userAgent", 40D), Map.entry("requestUri", 40D), Map.entry("method", 16D), + Map.entry("params", 50D), Map.entry("time", 16D), Map.entry("exception", 60D), + Map.entry("serviceId", 24D)); + + private final SysLogMapper mapper; + private final SysFileService files; + private final AsyncTaskExecutorService tasks; + private final ExcelConfigProperties excelConfig; + + public AsyncTaskProgressVO submit(SysLogExportDTO dto) { + SysLogExportDTO query = snapshot(dto); + ExcelExportStyleHandler style = new ExcelExportStyleHandler(excelConfig.getExportStyle(), COLUMN_WIDTHS); + return tasks.submitWithResult("admin:export:log", "日志导出", 1, + reporter -> generate(query, reporter, style, this::upload)); + } + + /** 文件完整生成后才写出响应,失败时仍可返回正常的错误提示。 */ + public void export(HttpServletResponse response, SysLogExportDTO dto) throws Exception { + SysLogExportDTO query = snapshot(dto); + ExcelExportStyleHandler style = new ExcelExportStyleHandler(excelConfig.getExportStyle(), COLUMN_WIDTHS); + generate(query, null, style, file -> BatchExportSupport.respond(file, FILE_NAME, response)); + } + + private SysLogExportDTO snapshot(SysLogExportDTO dto) { + SysLogExportDTO query = BeanUtil.toBean(dto, SysLogExportDTO.class); + LocalDateTime[] range = dto.getCreateTime(); + if (range != null && range.length > 0 && Arrays.stream(range).anyMatch(Objects::nonNull)) { + if (range.length != 2 || range[0] == null || range[1] == null || range[0].isAfter(range[1])) { + throw new AsyncTaskException("请选择有效的日志创建时间范围"); + } + query.setCreateTime(range.clone()); + } else { + query.setCreateTime(null); + } + query.setIds(dto.getIds() == null ? null : dto.getIds().clone()); + return query; + } + + private T 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(1, 0); + if (total > maximum) throw new AsyncTaskException("导出数据超过单工作表支持的 " + maximum + " 条,请缩小筛选范围"); + if (reporter != null) { + reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(total, BatchExportSupport.BATCH_SIZE, 2), + "共 " + total + " 条日志,正在生成 Excel"); + } + Path output = Files.createTempFile("log-export-", ".xlsx"); + try { + long written = 0; + try (ExcelWriter writer = EasyExcel.write(output.toFile()).head(SysLog.class) + .registerConverter(LocalDateTimeStringConverter.INSTANCE) + .registerConverter(LongStringConverter.INSTANCE) + .registerWriteHandler(style).build()) { + WriteSheet sheet = EasyExcel.writerSheet("操作日志").build(); + List rows = first.getRecords(); + while (!rows.isEmpty()) { + written += rows.size(); + if (written > total) throw new AsyncTaskException("导出期间日志数据发生变化,请重试"); + writer.write(rows, sheet); + if (reporter != null) reporter.advance("已写入 " + written + " / " + total + " 条日志"); + rows = fetch(query, rows.get(rows.size() - 1).getId()).getRecords(); + } + if (written != total) throw new AsyncTaskException("导出期间日志数据发生变化,请重试"); + } + if (reporter != null) reporter.advance("文件已生成,正在保存导出结果"); + return consumer.consume(output); + } finally { + Files.deleteIfExists(output); + } + } + + private Page 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/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 @@ - +