From c305ca946e21fb04229897c2fec9c561fdd3e7f2 Mon Sep 17 00:00:00 2001 From: leijiaping Date: Tue, 15 Sep 2026 12:14:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E5=90=AF=E5=81=9C/?= =?UTF-8?q?=E6=B3=A8=E9=94=80=E3=80=81=E6=9C=BA=E6=9E=84=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E3=80=81=E6=89=8B=E6=9C=BA=E5=8F=B7=E6=9F=A5?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E3=80=81=E6=9C=BA=E6=9E=84=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 用户管理:新增启用(user/enable)/禁用(user/disable)/注销(user/admin_cancel)接口, 平台超管可操作全部用户,机构管理员限本机构;注销带分布式锁、禁自己/超管守卫 2. 禁用/注销立即踢下线:Redis黑名单(user:disabled:{uid}) + JWT过滤器拦截, TTL与token有效期一致 3. 系统管理员查看各机构账号:GET user/page/org_account(机构/账号/手机号等筛选) 4. 按手机号查上传/发送/接收记录:GET user_file/record/page_by_phone, 发送/接收方向按部署侧 file.transfer.location 自动映射 5. 机构上传配置:新表 x_upload_config + upload_config/info|modify|check 接口, 上传总开关/后缀白名单/单文件大小限制,网盘落库时强制校验 6. 加固:UserInfoDTO.setPassword 缺省密码未配置时不再抛 rawPassword cannot be null 7. 新增 x_upload_config 建表脚本(upgrade-20260915-release.sql 为上线版)、 生产部署说明、前端接口文档×2、运维脚本 sfile-ctl.sh、AGENTS.md --- .gitignore | 3 +- AGENTS.md | 74 ++++ db/init-table-postgres-full.sql | 30 +- db/init-table.sql | 18 + db/upgrade-20260914-upload-config.sql | 62 +++ db/upgrade-20260915-release.sql | 90 ++++ docker-test/application.yml | 96 +++++ docs/接口文档-机构上传配置.md | 181 ++++++++ docs/接口文档-用户管理与记录查询.md | 407 ++++++++++++++++++ docs/生产部署说明.md | 220 ++++++++++ scripts/sfile-ctl.sh | 209 +++++++++ .../common/constant/CommonConstants.java | 5 +- .../com/senmesh/security/dto/UserInfoDTO.java | 11 +- .../jwt/JwtAuthenticationTokenFilter.java | 12 +- .../security/util/UserDisabledCache.java | 64 +++ .../sfile/constant/FileRecordTypeEnum.java | 53 +++ .../controller/UploadConfigController.java | 52 +++ .../sfile/controller/UserFileController.java | 9 + .../senmesh/sfile/dao/UploadConfigMapper.java | 15 + .../com/senmesh/sfile/dao/UserFileMapper.java | 10 + .../senmesh/sfile/dto/UploadConfigDTO.java | 46 ++ .../sfile/dto/UploadConfigModifyDTO.java | 44 ++ .../senmesh/sfile/dto/UserFileRecordDTO.java | 43 ++ .../sfile/dto/UserFileRecordQueryDTO.java | 70 +++ .../senmesh/sfile/entity/UploadConfig.java | 43 ++ .../senmesh/sfile/mapper/UserFileMapper.xml | 38 ++ .../sfile/service/UploadConfigService.java | 38 ++ .../sfile/service/UserFileService.java | 10 + .../impl/NormalUserFileServiceImpl.java | 21 + .../service/impl/UploadConfigServiceImpl.java | 170 ++++++++ .../service/impl/UserFileServiceImpl.java | 91 ++++ .../user/controller/UserController.java | 26 ++ .../usercenter/user/dao/UserMapper.java | 17 + .../usercenter/user/dto/OrgAccountDTO.java | 75 ++++ .../user/dto/OrgAccountQueryDTO.java | 72 ++++ .../user/dto/UserStatusOperateDTO.java | 30 ++ .../usercenter/user/mapper/UserMapper.xml | 64 +++ .../usercenter/user/service/UserService.java | 31 ++ .../user/service/impl/UserServiceImpl.java | 95 ++++ 39 files changed, 2637 insertions(+), 8 deletions(-) create mode 100644 AGENTS.md create mode 100644 db/upgrade-20260914-upload-config.sql create mode 100644 db/upgrade-20260915-release.sql create mode 100644 docker-test/application.yml create mode 100644 docs/接口文档-机构上传配置.md create mode 100644 docs/接口文档-用户管理与记录查询.md create mode 100644 docs/生产部署说明.md create mode 100755 scripts/sfile-ctl.sh create mode 100644 src/main/java/com/senmesh/security/util/UserDisabledCache.java create mode 100644 src/main/java/com/senmesh/sfile/constant/FileRecordTypeEnum.java create mode 100644 src/main/java/com/senmesh/sfile/controller/UploadConfigController.java create mode 100644 src/main/java/com/senmesh/sfile/dao/UploadConfigMapper.java create mode 100644 src/main/java/com/senmesh/sfile/dto/UploadConfigDTO.java create mode 100644 src/main/java/com/senmesh/sfile/dto/UploadConfigModifyDTO.java create mode 100644 src/main/java/com/senmesh/sfile/dto/UserFileRecordDTO.java create mode 100644 src/main/java/com/senmesh/sfile/dto/UserFileRecordQueryDTO.java create mode 100644 src/main/java/com/senmesh/sfile/entity/UploadConfig.java create mode 100644 src/main/java/com/senmesh/sfile/service/UploadConfigService.java create mode 100644 src/main/java/com/senmesh/sfile/service/impl/UploadConfigServiceImpl.java create mode 100644 src/main/java/com/senmesh/usercenter/user/dto/OrgAccountDTO.java create mode 100644 src/main/java/com/senmesh/usercenter/user/dto/OrgAccountQueryDTO.java create mode 100644 src/main/java/com/senmesh/usercenter/user/dto/UserStatusOperateDTO.java diff --git a/.gitignore b/.gitignore index 5eac309..ede0056 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ build/ !**/src/test/**/build/ ### VS Code ### -.vscode/ \ No newline at end of file +.vscode/.DS_Store +.commandcode/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..979ac72 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md — s-file(alita-bjy-uygur)编码代理指南 + +信创文件传输/网盘后端服务:内网与外网之间做文件安全传输("内穿外/外传内"),附带个人网盘、分享、机构与用户管理、通知等能力。单 Maven 模块,Spring Boot 可执行 jar,Docker 部署(`src/main/resources/Dockerfile`)。 + +## 技术栈(务必匹配,勿升级) + +- **Java 8** + Spring Boot **2.4.2**(父 POM 管理) +- ORM:**MyBatis-Plus 3.4.0**(注解/BaseMapper)+ 自研 `com.senmesh.tkmybatis` 封装 + PageHelper 1.4.6 + 自定义分页拦截器 +- 数据库:**达梦 DM8**(信创主力,另有 MySQL/PG 驱动),Druid 连接池,SQL 需多库兼容 +- 安全:Spring Security + 自研 JWT(`com.senmesh.security`),Redisson 分布式锁,RabbitMQ +- 文档:springfox swagger2 2.7.0;Lombok + MapStruct 1.2.0 + +## 常用命令 + +```bash +# 编译(需 JDK 8,例如本机 Zulu 1.8) +export JAVA_HOME=/Users/leijiaping/Library/Java/JavaVirtualMachines/azul-1.8.0_452/Contents/Home +mvn compile -DskipTests + +# 测试目录基本为空(仅 PwdTest),项目无自动化测试,验证靠编译 + 自查 +``` + +**已知依赖问题(重要)**:商业依赖 `e-iceblue:spire.pdf:3.7.4` 在阿里云/e-iceblue 官方仓库均不存在(官方仓库只有 9.x+)。本地仓库 `~/.m2/repository/e-iceblue/spire.pdf/3.7.4/` 现已安装一个**手工编译级 stub jar**(仅为 `PdfUtils.java` 引用的类签名生成),使 `mvn package` 可以通过;**代价是运行 PDF 盖章功能(`PdfUtils.pdfStampPerPage`)会失败**。正式发布前需从公司私服获取真实 jar 覆盖。全量快速编译验证可跳过 PdfUtils.java(详见下方命令)。 + +```bash +mvn dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt # 需联网 +$JAVA_HOME/bin/javac -encoding UTF-8 -cp "$(cat /tmp/cp.txt)" \ + -sourcepath src/main/java -d /tmp/v \ + $(find src/main/java -name "*.java" ! -name "PdfUtils.java") +``` + +## 包结构地图(`com.senmesh`) + +| 包 | 职责 | +|---|---| +| `usercenter` | 用户中心:`user`(用户/机构用户/注销)、`org`、`authority`(角色/权限)、`notice`(通知)、`auth` 等,子模块按「controller/service/impl/dto/entity/dao/mapper(XML)/constant/enums」分目录 | +| `sfile` | 文件业务:`UserFile`(上传/传输/分享记录,核心表 `x_user_file`)、`TransferUserFile`、`ShareUserFile`、`TransferFileMonitorJob`(定时扫描传输目录) | +| `component/file` | 物理文件上传组件(`xs_file_record`、`/file/upload`、`FileProperties`) | +| `common` | `AlitaResult`、`PageQueryDTO`、`BaseEntity`、`UserContextHolder`、异常体系 | +| `security` | JWT 过滤器、登录、`UserInfoDTO` | +| `frame/redis` | Redisson 封装:`RedissonUtils`、`@AutoUnLock`、`LockDTO` | +| `mybatis` / `mybatisplus` / `tkmybatis` | 分页拦截器、`@MapperScan`、`MyMapper` 基类 | +| `mq`、`codesetting`、`config` 等 | 其他支撑模块 | + +**注意**:源码树里有同包覆写的框架类(`org/springframework/...`、`com/senmesh/swagger`),改动前先确认是否为刻意覆写,不要删除。 + +## 新增一个接口的标准动作 + +1. **DTO**:查询 DTO 继承 `PageQueryDTO`(自带 pageNo/pageSize/needPage/pageResult);出参 DTO 继承 `BaseDTO`(用 `@SuperBuilder`);时间字段统一 `Date` + `@DateTimeFormat` + `@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone="GMT+8")`。 +2. **Entity**:继承 `BaseEntity`(带机构的继承 `OrgBaseEntity`),`@TableName` 注解,逻辑删除字段 `deleted`。 +3. **Mapper**:接口继承 `MyMapper`(已有 `saveEntity`/`updateEntitySelective`/`getById`/`deleteEntity`,自动填充 createBy/updateBy),放 `**/dao/`;**自定义 SQL 的 XML 放同名 java 源码目录的 `mapper/` 下**(pom 已配置打包 java 目录下 `**/*.xml`),XML 中参数一律 `@Param("param")` + `param.xxx` 引用。 +4. **Service**:接口 + `impl`。权限校验用命令式断言(项目无 @PreAuthorize): + - 平台超管:`Assert.isTrue(UserContextHolder.isSuperior(), "当前用户无操作权限")`,注意字段拼写就是 `supeiror`(历史笔误,勿"修复") + - 机构管理员:`UserContextHolder.isAdministrator()`;平台机构:`checkPlatformOrg()` +5. **Controller**:返回一律 `AlitaResult`(`AlitaResult.success(data)`);`@Api(tags=...)` + `@ApiOperation`;分页接口返回 `AlitaResult>`,模式:Service 把 queryDTO 传给 mapper 即自动分页,Controller 返回 `queryDTO.getPageResult()`。查询类用 GET + query 参数绑定,操作类用 POST + `@Validated @RequestBody`。 +6. **URL 前缀有语义**:`anon/**` 匿名(清单在配置 `security.anonymous`)、`unac/**` 已登录未激活可用、`authed/**` 已实名;普通前缀 = 登录即可。 + +## 关键业务概念 + +- **用户**(`uc_user`):`status` 1待激活/2已激活/3已注销 + `enabled` 启用开关 + 逻辑删除 `deleted`,三者独立。登录侧实时校验 `enabled=TRUE and status!=3`,禁用/注销后用户下次请求即失效。注销有两套:自助申请流(`UserCancelService`,`uc_user_cancel` 表)与管理员直接注销(`user/admin_cancel`)。同一手机号可对应多个账号(企业+个人)。 +- **机构**:`xs_org`(`parent_id` 树)+ `uc_org_user` 中间表(含 `administrator` 标志,一个用户可在多个机构,登录时选定 orgId)。**用户表没有 org_id**。平台机构固定 `orgId=1`(`CommonConstants.PLATFORM_ORG_ID`)。 +- **文件记录**(`x_user_file`):上传/发送/接收/分享全在这一张表,靠 `root_type` 区分:`1`普通(上传)、`2`内穿外、`3`外传内、`4`我的分享;`file_status`:1正常/2失效/11解析中/12待审核/13传输中/14失败。表里只有 `user_id`,无手机号,按手机号查需 JOIN `uc_user.phone`。 +- **传输方向映射**(`file.transfer.location`:1内网/2外网):发送记录 = 内网端 rootType 2 / 外网端 rootType 3;接收记录取反。手机号只在传输 ZIP 包内 `transferMetadata.json`,接收端解包时靠它反查本地用户。 + +## 陷阱清单 + +- 只能写 **Java 8** 语法(无 `var`/`Set.of`/record/switch 表达式;集合用 guava `Sets.newHashSet` 等也行)。 +- SQL 要兼容 DM8:布尔字面量用 `TRUE/FALSE`(bit 列),时间用 `now()`;避免 MySQL 专属函数;分页依赖拦截器,XML 里不要手写 limit。 +- XML 放错位置不会被打包——必须放 java 源码目录(与现有 `UserMapper.xml`、`UserFileMapper.xml` 同级模式)。 +- 驼峰映射由 MyBatis-Plus 默认开启,XML 直接 `SELECT u.real_name` 即可映射 `realName`;列别名规避同名冲突(如 `u.type AS user_type`)。 +- 注销/禁用等敏感操作要防并发:参考 `userCancel` 的 `RedissonUtils.tryLockAutomatically(LockDTO...)` + `@AutoUnLock` 模式;禁止注销/禁用自己与超管。 +- 数据库初始化脚本在 `db/`(`init-table.sql` MySQL 版、`init-table-postgres-full.sql` PG 版),改表结构需同步两个版本;增量变更另建 `db/upgrade-YYYYMMDD-*.sql`(现有:`upgrade-20260914-upload-config.sql`),历史上有过 DDL 只加脚本、不改 init 的情况,新环境安装依赖 init 脚本,**改表务必 init 与 upgrade 双写**。 +- 给前端的接口变更请同步维护 `docs/` 下的接口文档(现有:`接口文档-用户管理与记录查询.md`、`接口文档-机构上传配置.md`)。 +- `.commandcode/` 为本地工具产物,勿提交。 diff --git a/db/init-table-postgres-full.sql b/db/init-table-postgres-full.sql index 2f78dc0..15679c1 100644 --- a/db/init-table-postgres-full.sql +++ b/db/init-table-postgres-full.sql @@ -2018,5 +2018,31 @@ COMMENT ON COLUMN x_file_risk.update_by IS '修改人'; COMMENT ON COLUMN x_file_risk.update_time IS '修改时间'; COMMENT ON COLUMN x_file_risk.proxy_by IS '代理人'; - - +CREATE TABLE x_upload_config ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT DEFAULT NULL, + upload_enabled BOOLEAN DEFAULT TRUE, + allow_all_type BOOLEAN DEFAULT TRUE, + allowed_suffixes VARCHAR(1024) DEFAULT NULL, + max_file_size BIGINT DEFAULT NULL, + deleted BOOLEAN DEFAULT FALSE, + create_by BIGINT DEFAULT NULL, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_by BIGINT DEFAULT NULL, + update_time TIMESTAMP DEFAULT NULL, + proxy_by BIGINT DEFAULT NULL, + CONSTRAINT uk_upload_config_org_id UNIQUE (org_id) +); +COMMENT ON TABLE x_upload_config IS '机构上传配置'; +COMMENT ON COLUMN x_upload_config.id IS '主键ID'; +COMMENT ON COLUMN x_upload_config.org_id IS '机构id'; +COMMENT ON COLUMN x_upload_config.upload_enabled IS '是否允许上传'; +COMMENT ON COLUMN x_upload_config.allow_all_type IS '是否允许全部类型(false时按allowed_suffixes白名单校验)'; +COMMENT ON COLUMN x_upload_config.allowed_suffixes IS '允许的文件后缀(逗号分隔,小写),allow_all_type=false时生效'; +COMMENT ON COLUMN x_upload_config.max_file_size IS '单文件大小上限(MB),空或0不限制'; +COMMENT ON COLUMN x_upload_config.deleted IS '是否删除'; +COMMENT ON COLUMN x_upload_config.create_by IS '创建人'; +COMMENT ON COLUMN x_upload_config.create_time IS '创建时间'; +COMMENT ON COLUMN x_upload_config.update_by IS '修改人'; +COMMENT ON COLUMN x_upload_config.update_time IS '修改时间'; +COMMENT ON COLUMN x_upload_config.proxy_by IS '代理人'; diff --git a/db/init-table.sql b/db/init-table.sql index 60ecfb2..a53ac74 100644 --- a/db/init-table.sql +++ b/db/init-table.sql @@ -1260,4 +1260,22 @@ CREATE TABLE `x_file_risk` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件风险'; + +CREATE TABLE `x_upload_config` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `org_id` bigint(20) DEFAULT NULL COMMENT '机构id', + `upload_enabled` bit(1) DEFAULT b'1' COMMENT '是否允许上传', + `allow_all_type` bit(1) DEFAULT b'1' COMMENT '是否允许全部类型(false时按allowed_suffixes白名单校验)', + `allowed_suffixes` varchar(1024) DEFAULT NULL COMMENT '允许的文件后缀(逗号分隔,小写),allow_all_type=false时生效', + `max_file_size` bigint(20) DEFAULT NULL COMMENT '单文件大小上限(MB),空或0不限制', + `deleted` bit(1) DEFAULT b'0' COMMENT '是否删除', + `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` bigint(20) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `proxy_by` bigint(20) DEFAULT NULL COMMENT '代理人', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_org_id` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='机构上传配置'; + SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/db/upgrade-20260914-upload-config.sql b/db/upgrade-20260914-upload-config.sql new file mode 100644 index 0000000..35a16dd --- /dev/null +++ b/db/upgrade-20260914-upload-config.sql @@ -0,0 +1,62 @@ +-- ============================================================= +-- 升级脚本:机构上传配置(文件上传类型/大小限制) +-- 日期:2026-09-14 +-- 说明:新增 x_upload_config 表(一行对应一个机构)。 +-- 机构管理员可配置本机构允许上传的文件后缀白名单、 +-- 单文件大小上限、上传总开关;未配置的机构默认允许全部。 +-- 拦截点:网盘上传落库(normal_user_file/create)。 +-- 执行方式:MySQL/达梦(DM8 MySQL兼容模式)执行第 1 节; +-- PostgreSQL 执行第 2 节。 +-- ============================================================= + +-- ------------------------------------------------------------- +-- 1. MySQL / 达梦 DM8 +-- ------------------------------------------------------------- +CREATE TABLE `x_upload_config` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `org_id` bigint(20) DEFAULT NULL COMMENT '机构id', + `upload_enabled` bit(1) DEFAULT b'1' COMMENT '是否允许上传', + `allow_all_type` bit(1) DEFAULT b'1' COMMENT '是否允许全部类型(false时按allowed_suffixes白名单校验)', + `allowed_suffixes` varchar(1024) DEFAULT NULL COMMENT '允许的文件后缀(逗号分隔,小写),allow_all_type=false时生效', + `max_file_size` bigint(20) DEFAULT NULL COMMENT '单文件大小上限(MB),空或0不限制', + `deleted` bit(1) DEFAULT b'0' COMMENT '是否删除', + `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` bigint(20) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `proxy_by` bigint(20) DEFAULT NULL COMMENT '代理人', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_org_id` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='机构上传配置'; + +-- ------------------------------------------------------------- +-- 2. PostgreSQL +-- ------------------------------------------------------------- +CREATE TABLE x_upload_config ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT DEFAULT NULL, + upload_enabled BOOLEAN DEFAULT TRUE, + allow_all_type BOOLEAN DEFAULT TRUE, + allowed_suffixes VARCHAR(1024) DEFAULT NULL, + max_file_size BIGINT DEFAULT NULL, + deleted BOOLEAN DEFAULT FALSE, + create_by BIGINT DEFAULT NULL, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_by BIGINT DEFAULT NULL, + update_time TIMESTAMP DEFAULT NULL, + proxy_by BIGINT DEFAULT NULL, + CONSTRAINT uk_upload_config_org_id UNIQUE (org_id) +); +COMMENT ON TABLE x_upload_config IS '机构上传配置'; +COMMENT ON COLUMN x_upload_config.id IS '主键ID'; +COMMENT ON COLUMN x_upload_config.org_id IS '机构id'; +COMMENT ON COLUMN x_upload_config.upload_enabled IS '是否允许上传'; +COMMENT ON COLUMN x_upload_config.allow_all_type IS '是否允许全部类型(false时按allowed_suffixes白名单校验)'; +COMMENT ON COLUMN x_upload_config.allowed_suffixes IS '允许的文件后缀(逗号分隔,小写),allow_all_type=false时生效'; +COMMENT ON COLUMN x_upload_config.max_file_size IS '单文件大小上限(MB),空或0不限制'; +COMMENT ON COLUMN x_upload_config.deleted IS '是否删除'; +COMMENT ON COLUMN x_upload_config.create_by IS '创建人'; +COMMENT ON COLUMN x_upload_config.create_time IS '创建时间'; +COMMENT ON COLUMN x_upload_config.update_by IS '修改人'; +COMMENT ON COLUMN x_upload_config.update_time IS '修改时间'; +COMMENT ON COLUMN x_upload_config.proxy_by IS '代理人'; diff --git a/db/upgrade-20260915-release.sql b/db/upgrade-20260915-release.sql new file mode 100644 index 0000000..e61f122 --- /dev/null +++ b/db/upgrade-20260915-release.sql @@ -0,0 +1,90 @@ +-- ===================================================================== +-- s-file 上线数据库脚本(2026-09-15 版本) +-- 适用场景:已有生产库升级(老版本 → 当前版本) +-- +-- 本次版本(用户启停/注销、机构账号查询、手机号查记录、机构上传配置) +-- 相对老版本的数据库变更只有一项: +-- 新增表 x_upload_config(机构上传配置) +-- 其余功能(启停/注销/查询)全部复用已有表字段,无需任何变更。 +-- +-- 【执行说明】 +-- * 生产主力库为达梦 DM8(MySQL 兼容模式)→ 只执行【第一部分】 +-- * 若目标库是 PostgreSQL → 只执行【第二部分】 +-- * 执行前建议备份;执行后核对【第三部分】的验证 SQL +-- * 可重复执行(幂等):表已存在时再执行会报"已存在"错误,属正常, +-- 说明该脚本已执行过,无需处理 +-- ===================================================================== + + +-- ===================================================================== +-- 第一部分:达梦 DM8 / MySQL +-- ===================================================================== + +CREATE TABLE x_upload_config ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + org_id BIGINT DEFAULT NULL COMMENT '机构id', + upload_enabled BIT(1) DEFAULT b'1' COMMENT '是否允许上传', + allow_all_type BIT(1) DEFAULT b'1' COMMENT '是否允许全部类型(false时按allowed_suffixes白名单校验)', + allowed_suffixes VARCHAR(1024) DEFAULT NULL COMMENT '允许的文件后缀(逗号分隔,小写),allow_all_type=false时生效', + max_file_size BIGINT DEFAULT NULL COMMENT '单文件大小上限(MB),空或0不限制', + deleted BIT(1) DEFAULT b'0' COMMENT '是否删除', + create_by BIGINT DEFAULT NULL COMMENT '创建人', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_by BIGINT DEFAULT NULL COMMENT '修改人', + update_time DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + proxy_by BIGINT DEFAULT NULL COMMENT '代理人', + PRIMARY KEY (id) USING BTREE, + UNIQUE KEY uk_org_id (org_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='机构上传配置'; + +-- 说明:不需要初始化数据。 +-- 未配置的机构 = 默认策略(允许上传全部类型、不限大小), +-- 由各机构管理员在管理后台"上传配置"页面自行配置后落库。 + + +-- ===================================================================== +-- 第二部分:PostgreSQL +-- ===================================================================== + +-- CREATE TABLE x_upload_config ( +-- id BIGSERIAL PRIMARY KEY, +-- org_id BIGINT DEFAULT NULL, +-- upload_enabled BOOLEAN DEFAULT TRUE, +-- allow_all_type BOOLEAN DEFAULT TRUE, +-- allowed_suffixes VARCHAR(1024) DEFAULT NULL, +-- max_file_size BIGINT DEFAULT NULL, +-- deleted BOOLEAN DEFAULT FALSE, +-- create_by BIGINT DEFAULT NULL, +-- create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, +-- update_by BIGINT DEFAULT NULL, +-- update_time TIMESTAMP DEFAULT NULL, +-- proxy_by BIGINT DEFAULT NULL, +-- CONSTRAINT uk_upload_config_org_id UNIQUE (org_id) +-- ); +-- COMMENT ON TABLE x_upload_config IS '机构上传配置'; +-- COMMENT ON COLUMN x_upload_config.id IS '主键ID'; +-- COMMENT ON COLUMN x_upload_config.org_id IS '机构id'; +-- COMMENT ON COLUMN x_upload_config.upload_enabled IS '是否允许上传'; +-- COMMENT ON COLUMN x_upload_config.allow_all_type IS '是否允许全部类型(false时按allowed_suffixes白名单校验)'; +-- COMMENT ON COLUMN x_upload_config.allowed_suffixes IS '允许的文件后缀(逗号分隔,小写),allow_all_type=false时生效'; +-- COMMENT ON COLUMN x_upload_config.max_file_size IS '单文件大小上限(MB),空或0不限制'; +-- COMMENT ON COLUMN x_upload_config.deleted IS '是否删除'; +-- COMMENT ON COLUMN x_upload_config.create_by IS '创建人'; +-- COMMENT ON COLUMN x_upload_config.create_time IS '创建时间'; +-- COMMENT ON COLUMN x_upload_config.update_by IS '修改人'; +-- COMMENT ON COLUMN x_upload_config.update_time IS '修改时间'; +-- COMMENT ON COLUMN x_upload_config.proxy_by IS '代理人'; + + +-- ===================================================================== +-- 第三部分:执行后验证(达梦/MySQL 语法) +-- ===================================================================== + +-- 1) 表存在且为空(0 行正常) +-- SELECT COUNT(*) FROM x_upload_config; + +-- 2) 唯一约束存在(应返回 uk_org_id) +-- SELECT INDEX_NAME FROM USER_INDEXES WHERE TABLE_NAME = 'X_UPLOAD_CONFIG'; + +-- 3) (可选)回滚脚本 —— 如需回滚上线: +-- DROP TABLE x_upload_config; diff --git a/docker-test/application.yml b/docker-test/application.yml new file mode 100644 index 0000000..9bc6c00 --- /dev/null +++ b/docker-test/application.yml @@ -0,0 +1,96 @@ +server: + port: 8080 + +spring: + application: + name: s-file + datasource: + url: jdbc:postgresql://sfile-pg:5432/s_file + driver-class-name: org.postgresql.Driver + username: postgres + password: sfile123 + druid: + initial-size: 2 + min-idle: 2 + max-active: 10 + validation-query: SELECT 1 + redis: + host: sfile-redis + port: 6379 + timeout: 5000 + lettuce: + pool: + max-active: 8 + max-idle: 4 + rabbitmq: + host: sfile-mq + port: 5672 + username: guest + password: guest + servlet: + multipart: + max-file-size: 500MB + max-request-size: 500MB + main: + allow-bean-definition-overriding: true + +mybatis-plus: + mapper-locations: classpath*:com/senmesh/**/mapper/*.xml + +# 平台编码(init-data 中 uc_platform.platform_code=1) +system: + code: 1 + +# 发号器雪花 workerId(键格式: code.snowflake.workid.<容器IP>) +"[code.snowflake.workid.172.20.0.10]": 1 + +security: + jwt: + enable: true + secret: sfile-functional-test-secret-key-please-change-0123456789abcdef + expiration: 86400000 + header: Authorization + ignoring: /error,/favicon.ico + login-page: /login + superior-only: false + use-local: true + allow-if-all-abstain-decisions: true + oauth2: + enable: false + not-logs: "" + +# 新用户初始密码 +user: + default: + password: Qaz123567!@# + +file: + upload-dir: /data/upload + storage: + type: local + transfer: + # 内网侧 + location: "1" + send-dir: /data/transfer/send + receive-dir: /data/transfer/receive + unpack-risk-check: false + +dify: + risk: + rule: + api-key: test-rule-key + scan: + api-key: test-scan-key + +rabbitmq: + custom: + topic-exchange-name: s-file-test-topic + delayed-exchange-name: s-file-test-delayed + broadcast-id: s-file-test-broadcast + +swagger: + enable: false + +logging: + level: + com.senmesh: info diff --git a/docs/接口文档-机构上传配置.md b/docs/接口文档-机构上传配置.md new file mode 100644 index 0000000..90bb74e --- /dev/null +++ b/docs/接口文档-机构上传配置.md @@ -0,0 +1,181 @@ +# 前端接口文档 —— 机构上传配置(文件上传类型/大小限制) + +> 版本:v1.0(2026-09-14) +> 适用后端:s-file(alita-bjy-uygur) +> Swagger 分组:「机构上传配置」 + +## 功能说明 + +机构管理员可以为本机构配置网盘上传策略: + +- **上传总开关**:关闭后本机构用户不能上传任何文件到网盘; +- **类型白名单**:可限制只允许上传指定后缀的文件(如 `doc、docx、pdf、jpg`); +- **单文件大小上限**:单位 MB,超过即拒绝。 + +规则约定: + +- 配置**按机构隔离**,作用于当前登录机构,管理的是本机构用户的**网盘上传**(`normal_user_file/create` 落库时强制校验); +- **未配置过的机构 = 默认策略**:允许上传、允许全部类型、不限大小(对现有用户完全兼容); +- 个人用户(无登录机构)不受机构配置限制; +- 后缀不区分大小写,配置时可带可不带前导点(`PDF`、`.pdf`、`pdf` 等价),服务端统一转小写存储; +- 文件夹创建不受类型/大小校验影响; +- 文件**传输(发送/接收)与分享不受此配置影响**,该配置只管上传。 + +## 通用约定 + +与既有接口一致:登录态调用;统一返回 `AlitaResult`(`code="200"` 且 `success=true` 成功;业务失败 `success=false`,`msg` 为提示文案);时间格式 `yyyy-MM-dd HH:mm:ss`。 + +--- + +## 1. 查询当前机构的上传配置 + +- **URL**:`GET /upload_config/info` +- **权限**:所有登录用户(前端可在上传前读取并据此过滤可选文件类型) +- **参数**:无(按当前登录机构查询) +- **返回**:`AlitaResult` + +**UploadConfigDTO 字段** + +| 字段 | 类型 | 说明 | +|---|---|---| +| orgId | Long | 机构 id | +| uploadEnabled | Boolean | 是否允许上传 | +| allowAllType | Boolean | 是否允许全部类型 | +| allowedSuffixes | List<String> | 允许的后缀(小写、不含点),`allowAllType=false` 时有值,否则为 null | +| maxFileSize | Long | 单文件大小上限(MB),null 或 0 表示不限制 | +| configured | Boolean | **机构是否已配置过**。`false` 表示当前是默认策略(允许上传全部类型、不限大小),前端此时可不限制文件选择器 | + +**返回示例(已配置)** + +```json +{ + "code": "200", + "success": true, + "data": { + "orgId": 12, + "uploadEnabled": true, + "allowAllType": false, + "allowedSuffixes": ["doc", "docx", "pdf", "jpg", "png"], + "maxFileSize": 200, + "configured": true + } +} +``` + +**返回示例(未配置过)** + +```json +{ + "code": "200", + "success": true, + "data": { + "orgId": 12, + "uploadEnabled": true, + "allowAllType": true, + "allowedSuffixes": null, + "maxFileSize": null, + "configured": false + } +} +``` + +--- + +## 2. 保存机构的上传配置 + +Upsert:该机构无配置则新建,有则更新。 + +- **URL**:`POST /upload_config/modify` +- **权限**:**机构管理员** 或 平台超管(其他用户返回「当前用户无操作权限」) +- **Body**(application/json) + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| uploadEnabled | Boolean | 是 | 是否允许上传 | +| allowAllType | Boolean | 是 | 是否允许全部类型 | +| allowedSuffixes | List<String> | 条件必填 | `allowAllType=false` 时必填且不能为空;后缀可带可不带点,自动小写去重 | +| maxFileSize | Long | 否 | 单文件上限(MB),null 或 0 不限制;传负数报错 | + +**校验失败提示** + +| 场景 | msg | +|---|---| +| 非机构管理员/超管 | 当前用户无操作权限 | +| 白名单为空 | 未允许全部类型时,允许的文件后缀不能为空 | +| maxFileSize ≤ 0 | 单文件大小上限必须大于0 | +| 并发修改 | 上传配置正在修改,请稍后重试 | + +**请求示例(只允许文档和图片,单个最大 200MB)** + +```json +POST /upload_config/modify +{ + "uploadEnabled": true, + "allowAllType": false, + "allowedSuffixes": ["doc", "docx", "xls", "xlsx", "pdf", "jpg", "png"], + "maxFileSize": 200 +} +``` + +**请求示例(禁止本机构上传)** + +```json +POST /upload_config/modify +{ + "uploadEnabled": false, + "allowAllType": true +} +``` + +**返回示例** + +```json +{ "code": "200", "success": true, "data": true } +``` + +--- + +## 3. 上传预检(可选,提升体验) + +按文件名/大小对当前机构配置做一次预检,用于**选择文件后、真正上传前**给出友好提示,避免白传一遍物理文件才被拒绝。 + +- **URL**:`GET /upload_config/check` +- **权限**:所有登录用户 +- **参数**(URL query) + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| fileName | String | 是 | 完整文件名(含后缀) | +| fileSize | Long | 否 | 文件大小(字节),不传则跳过大小校验 | + +- **返回**:通过时 `{"code":"200","success":true,"data":true}`;不通过时 `success=false`,`msg` 说明原因(与真实上传被拦截时的文案一致)。 + +**不通过返回示例** + +```json +{ + "code": "700", + "success": false, + "msg": "仅允许上传后缀为 doc、docx、pdf、jpg、png 的文件", + "data": null +} +``` + +--- + +## 4. 上传被拦截时的服务端行为 + +用户上传最终在网盘创建记录接口(`POST /normal_user_file/create`)被强制校验,不通过时接口返回 `success=false`,`msg` 为以下之一: + +| 场景 | msg | +|---|---| +| 机构关闭上传 | 当前机构已禁止上传文件,请联系机构管理员 | +| 后缀不在白名单 | 仅允许上传后缀为 doc、docx、pdf 的文件(动态列出) | +| 机构白名单为空(异常配置) | 当前机构未配置可上传的文件类型,请联系机构管理员 | +| 超过大小上限 | 上传文件过大,单文件最大不得超过200MB(动态数值) | + +**前端建议**: + +1. 上传页加载时调 `GET /upload_config/info`:`uploadEnabled=false` 时直接隐藏/禁用上传入口;`allowAllType=false` 时设置文件选择器的 accept 过滤; +2. 选择文件后可先调 `GET /upload_config/check` 预检(可选); +3. 真实上传两步(`/file/upload` → `/normal_user_file/create`)中第二步失败时直接 toast `msg`。 diff --git a/docs/接口文档-用户管理与记录查询.md b/docs/接口文档-用户管理与记录查询.md new file mode 100644 index 0000000..e7a4320 --- /dev/null +++ b/docs/接口文档-用户管理与记录查询.md @@ -0,0 +1,407 @@ +# 前端接口文档 —— 用户管理增强 & 机构账号查询 & 按手机号查询文件记录 + +> 版本:v1.0(2026-09-14) +> 适用后端:s-file(alita-bjy-uygur) +> Swagger 分组:「用户」「用户文件」 + +--- + +## 0. 通用约定 + +### 0.1 请求与认证 + +- 所有接口均需**登录后调用**,认证 token 按现有方式携带(header 或 cookie,与系统其他接口一致)。 +- 新增接口均要求**平台管理员(超管)身份**(`uc_user.supeiror = true`),非管理员调用统一返回失败,提示「当前用户无操作权限」。 + +### 0.2 统一返回结构 `AlitaResult` + +```json +{ + "requestId": "xxx", + "code": "200", + "success": true, + "msg": null, + "data": { ... } +} +``` + +| 字段 | 说明 | +|---|---| +| code | `"200"` 成功;业务失败时 success=false,msg 为错误提示(如「不能禁用当前登录账号」) | +| success | 是否成功 | +| data | 业务数据,失败时为 null | + +### 0.3 分页请求与返回 + +分页查询接口通过 **URL query 参数** 传递分页信息,返回结构 `Page`: + +``` +pageNo 第几页,默认 1 +pageSize 页宽,默认 20,上限 1000 +needPage 是否分页,默认 true(无需传) +``` + +```json +{ + "pageNo": 1, + "pageSize": 20, + "pageCount": 5, + "totalCount": 88, + "entityList": [ ... ] +} +``` + +### 0.4 通用枚举(前后端共用) + +**用户状态 `status`** + +| 值 | 含义 | +|---|---| +| 1 | 待激活 | +| 2 | 已激活 | +| 3 | 已注销 | + +**是否启用 `enabled`**:`true` 启用,`false` 禁用。 + +**文件状态 `fileStatus`** + +| 值 | 含义 | +|---|---| +| 1 | 正常 | +| 2 | 失效 | +| 11 | 解析中 | +| 12 | 待审核 | +| 13 | 传输中 | +| 14 | 失败 | + +**记录类型 `recordType`**(本文档 3.4 节专用) + +| 值 | 含义 | +|---|---| +| 1 | 上传记录 | +| 2 | 发送记录(本端发起文件传输) | +| 3 | 接收记录(本端接收文件传输) | + +> 发送/接收与部署侧有关,但**前端无需关心**:传入 `recordType` 后,后端按部署配置(内网/外网)自动映射到对应的 `root_type`;返回列表中每条记录也已回填 `recordType` / `recordTypeDesc`。 + +--- + +## 1. 用户管理:启用 / 禁用 / 注销 + +### 1.1 启用用户 + +将被禁用的账号重新启用(幂等:已启用的账号重复调用仍返回成功)。 + +- **URL**:`POST /user/enable` +- **权限**:平台管理员 +- **Body**(application/json) + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| userId | Long | 是 | 目标用户 id | + +- **返回**:`AlitaResult`,data 为 `true` +- **失败场景**:用户不存在;账号已注销(提示「已注销账号不可启用」);非管理员 + +**请求示例** + +```json +POST /user/enable +{ "userId": 10086 } +``` + +**返回示例** + +```json +{ "code": "200", "success": true, "data": true } +``` + +### 1.2 禁用用户 + +禁用账号,禁用后该用户无法登录/使用系统(后端登录态校验会实时读取启用状态,被禁用用户下次请求即被拒绝)。幂等:已禁用的账号重复调用仍返回成功。 + +- **URL**:`POST /user/disable` +- **权限**:平台管理员 +- **Body**(application/json) + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| userId | Long | 是 | 目标用户 id | + +- **返回**:`AlitaResult`,data 为 `true` +- **失败场景**: + - 用户不存在 + - `userId` 为当前登录人(提示「不能禁用当前登录账号」) + - 目标是超级管理员(提示「不能禁用超级管理员账号」) + - 目标已注销(提示「已注销账号无需禁用」) + +**请求示例** + +```json +POST /user/disable +{ "userId": 10086 } +``` + +### 1.3 注销用户(管理员直接注销) + +管理员将账号直接置为**已注销**(status=3)并同步禁用(enabled=false),**不进入注销申请审批流程**。注销后账号不可恢复、不可登录。幂等:已注销账号重复调用返回成功。 + +- **URL**:`POST /user/admin_cancel` +- **权限**:平台管理员 +- **Body**(application/json) + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| userId | Long | 是 | 目标用户 id | + +- **返回**:`AlitaResult`,data 为 null +- **失败场景**: + - 用户不存在 + - `userId` 为当前登录人(提示「不能注销当前登录账号」) + - 目标是超级管理员(提示「不能注销超级管理员账号」) + - 并发重复操作(提示「该用户正在注销,请勿重复操作」) + +**请求示例** + +```json +POST /user/admin_cancel +{ "userId": 10086 } +``` + +> **与既有接口的区别**:既有 `POST /user/cancel` 为机构内注销(限定当前登录机构、且要求目标账号启用中),供原「注销申请/同意」流程使用,保持不变;本接口供系统管理员在用户管理页面跨机构直接注销。 + +--- + +## 2. 系统管理员:查看各机构下的账号信息 + +### 2.1 分页查询机构账号 + +按机构维度查看账号信息。一行 = 一个机构下的一个账号(同一账号属于多个机构时会出现多行)。不传 `orgId` 时查询全部机构,传 `orgId` 时查看指定机构。 + +- **URL**:`GET /user/page/org_account` +- **权限**:平台管理员 +- **参数**(URL query) + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| orgId | Long | 否 | 机构 id,不传查全部机构 | +| orgNameLike | String | 否 | 机构名称,模糊匹配 | +| usernameLike | String | 否 | 账号,模糊匹配 | +| realNameLike | String | 否 | 真实姓名,模糊匹配 | +| phoneLike | String | 否 | 手机号,模糊匹配 | +| enabled | Boolean | 否 | 是否启用:true/false | +| status | Integer | 否 | 用户状态:1 待激活 / 2 已激活 / 3 已注销 | +| createTimeStart | String | 否 | 创建时间起,格式 `yyyy-MM-dd HH:mm:ss` | +| createTimeEnd | String | 否 | 创建时间止,格式 `yyyy-MM-dd HH:mm:ss` | +| pageNo / pageSize | Integer | 否 | 分页参数,默认 1 / 20 | + +- **返回**:`AlitaResult>` + +**OrgAccountDTO 字段** + +| 字段 | 类型 | 说明 | +|---|---|---| +| orgId | Long | 机构 id | +| orgName | String | 机构名称 | +| administrator | Boolean | 是否该机构的管理员 | +| userId | Long | 用户 id(可传给 1.1/1.2/1.3 接口做启停/注销操作) | +| username | String | 账号 | +| nickname | String | 昵称 | +| realName | String | 真实姓名 | +| phone | String | 手机号 | +| email | String | 邮箱 | +| userType | Integer | 用户类型:1 企业用户 / 2 个人用户 | +| enabled | Boolean | 是否启用 | +| status | Integer | 状态:1 待激活 / 2 已激活 / 3 已注销 | +| createTime | String | 创建时间 `yyyy-MM-dd HH:mm:ss` | + +- **排序规则**:机构 id 升序,机构管理员排前面,同机构内按用户 id 倒序。 + +**请求示例** + +``` +GET /user/page/org_account?orgNameLike=教育&phoneLike=138&enabled=false&pageNo=1&pageSize=20 +``` + +**返回示例** + +```json +{ + "code": "200", + "success": true, + "data": { + "pageNo": 1, + "pageSize": 20, + "pageCount": 1, + "totalCount": 2, + "entityList": [ + { + "orgId": 12, + "orgName": "XX市教育局", + "administrator": true, + "userId": 1001, + "username": "admin_xx", + "nickname": "张三", + "realName": "张三", + "phone": "13800000001", + "email": "zhang@xx.gov.cn", + "userType": 1, + "enabled": false, + "status": 2, + "createTime": "2026-08-01 10:00:00" + }, + { + "orgId": 12, + "orgName": "XX市教育局", + "administrator": false, + "userId": 1002, + "username": "user_xx01", + "nickname": "李四", + "realName": "李四", + "phone": "13800000002", + "email": null, + "userType": 1, + "enabled": true, + "status": 2, + "createTime": "2026-08-03 14:30:00" + } + ] + } +} +``` + +--- + +## 3. 按手机号查询文件记录(上传 / 发送 / 接收) + +### 3.1 分页查询 + +输入手机号,精确匹配该手机号名下(可能是多个账号)的全部文件记录,可按记录类型筛选。 + +- **URL**:`GET /user_file/record/page_by_phone` +- **权限**:平台管理员 +- **参数**(URL query) + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| phone | String | **是** | 用户手机号,精确匹配,同一手机号关联的所有账号均会命中 | +| recordType | Integer | 否 | 1 上传记录 / 2 发送记录 / 3 接收记录,**不传查全部三类** | +| fileNameLike | String | 否 | 文件名,模糊匹配 | +| fileType | String | 否 | 文件类型:`1` 文件 / `2` 文件夹 | +| fileStatus | String | 否 | 文件状态:1 正常 / 2 失效 / 11 解析中 / 12 待审核 / 13 传输中 / 14 失败 | +| createTimeBegin | String | 否 | 记录时间起,格式 `yyyy-MM-dd HH:mm:ss` | +| createTimeEnd | String | 否 | 记录时间止,格式 `yyyy-MM-dd HH:mm:ss` | +| pageNo / pageSize | Integer | 否 | 分页参数,默认 1 / 20 | + +- **返回**:`AlitaResult>` + +**UserFileRecordDTO 字段**(继承用户文件全部字段,以下为主要字段) + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | Long | 记录 id | +| phone | String | 归属手机号 | +| username | String | 归属账号 | +| realName | String | 归属真实姓名 | +| userType | Integer | 归属用户类型:1 企业用户 / 2 个人用户 | +| recordType | Integer | 记录类型:1 上传 / 2 发送 / 3 接收 | +| recordTypeDesc | String | 记录类型描述(「上传记录」「发送记录」「接收记录」) | +| userId | Long | 归属用户 id | +| rootType | String | 原始根类型:1 普通 / 2 内穿外 / 3 外传内(调试用,前端一般用 recordType 即可) | +| fileType | String | 文件大类:1 文件 / 2 文件夹 | +| fileName | String | 文件名 | +| fileCode | String | 物理文件编码 | +| fileSize | Long | 文件大小(字节) | +| fileStatus | String | 文件状态:1 正常 / 2 失效 / 11 解析中 / 12 待审核 / 13 传输中 / 14 失败 | +| createTime | String | 记录时间(上传/发送/接收发起时间)`yyyy-MM-dd HH:mm:ss` | +| endTime | String | 有效期(分享类记录使用),可能为 null | +| fileCategory | String | 文件种类(文档/图片/视频等,按后缀归类) | +| maxRiskLevel | String | 最高风险等级:1 重度 / 2 中度 / 3 轻度,可能为 null | + +- **排序**:记录 id 倒序(即时间倒序)。 +- **失败场景**:phone 为空;recordType 非法(提示「记录类型不合法,仅支持1上传/2发送/3接收」);非管理员。 + +**请求示例** + +``` +GET /user_file/record/page_by_phone?phone=13800000001&recordType=2&pageNo=1&pageSize=10 +``` + +**返回示例** + +```json +{ + "code": "200", + "success": true, + "data": { + "pageNo": 1, + "pageSize": 10, + "pageCount": 1, + "totalCount": 2, + "entityList": [ + { + "id": 9001, + "phone": "13800000001", + "username": "user_xx01", + "realName": "李四", + "userType": 1, + "recordType": 2, + "recordTypeDesc": "发送记录", + "userId": 1002, + "rootType": "2", + "fileType": "2", + "fileName": "2026年预算材料_20260912103000", + "fileCode": null, + "fileSize": null, + "fileStatus": "1", + "createTime": "2026-09-12 10:30:00", + "endTime": null, + "fileCategory": null, + "maxRiskLevel": null + }, + { + "id": 8802, + "phone": "13800000001", + "username": "user_xx01", + "realName": "李四", + "userType": 1, + "recordType": 1, + "recordTypeDesc": "上传记录", + "userId": 1002, + "rootType": "1", + "fileType": "1", + "fileName": "工作总结.docx", + "fileCode": "fc_3a7d92", + "fileSize": 1048576, + "fileStatus": "1", + "createTime": "2026-09-10 09:12:00", + "endTime": null, + "fileCategory": "文档", + "maxRiskLevel": null + } + ] + } +} +``` + +> **记录类型口径说明**(便于前端理解,无需处理): +> - 上传记录:用户在网盘中上传的文件/文件夹(rootType=1)。 +> - 发送记录:用户发起的文件传输(文件夹整体外发,传输中状态为 13,完成后变 1,失败为 14)。 +> - 接收记录:对端传来的文件在本端解包后归属到该手机号用户的记录(若开启风险审查,先为 12 待审核,通过后为 1)。 + +--- + +## 4. 错误处理建议 + +前端统一按 `success=false` 判定失败并 toast `msg`;特殊 code 沿用系统既有约定(`401` 未登录等,与现有接口一致)。 + +涉及本批接口的典型业务错误文案: + +| 场景 | msg | +|---|---| +| 非平台管理员操作 | 当前用户无操作权限 | +| 启用已注销账号 | 已注销账号不可启用 | +| 禁用/注销自己 | 不能禁用当前登录账号 / 不能注销当前登录账号 | +| 禁用/注销超管 | 不能禁用超级管理员账号 / 不能注销超级管理员账号 | +| recordType 非法 | 记录类型不合法,仅支持1上传/2发送/3接收 | +| phone 为空 | 手机号不能为空 | diff --git a/docs/生产部署说明.md b/docs/生产部署说明.md new file mode 100644 index 0000000..74bbbe4 --- /dev/null +++ b/docs/生产部署说明.md @@ -0,0 +1,220 @@ +# s-file 生产部署 / 上线说明 + +> 适用版本:2026-09-14(含用户启停/注销、机构账号查询、手机号查记录、机构上传配置、禁用踢下线黑名单) +> 构建产物:`target/s-file.jar`(Spring Boot 可执行 fat jar,需 JDK 8) + +--- + +## 0. 上线前必须处理(阻断项) + +### 0.1 替换 spire.pdf 占位 jar ⚠️ + +本机构开发机的 `~/.m2/repository/e-iceblue/spire.pdf/3.7.4/` 中目前安装的是**编译级 stub(空实现)jar**——它能保证编译打包通过,但 **PDF 盖章功能(`PdfUtils.pdfStampPerPage`)运行时必然失败**。 + +上线前必须: + +1. 从公司私服获取真实的 `spire.pdf-3.7.4.jar`(阿里云与 e-iceblue 官方仓库均无此版本); +2. 覆盖 `~/.m2/repository/e-iceblue/spire.pdf/3.7.4/spire.pdf-3.7.4.jar`(pom 同步覆盖); +3. 重新 `mvn clean package -DskipTests`; +4. 验证:解包 jar 中 `com/spire/pdf/PdfDocument.class` 应为真实实现(stub 版仅 300~500 字节)。 + +> 若该业务暂时不用 PDF 盖章,可带 stub 上线,但需明确告知团队。 + +### 0.2 提供生产配置文件 + +**仓库内不含任何运行配置**(application.yml 是外置的)。启动时通过 `spring.config.additional-location` 挂载。生产模板如下(按实际环境修改): + +```yaml +server: + port: 8080 + +spring: + application: + name: s-file + datasource: + # 生产为达梦 DM8;驱动 com.dameng.jdbc.driver(pom 已含依赖) + url: jdbc:dm://:5236/S_FILE + driver-class-name: com.dameng.jdbc.driver + username: + password: + druid: + initial-size: 5 + min-idle: 5 + max-active: 20 + validation-query: SELECT 1 + redis: # 必须可用:登录缓存 + 禁用/注销踢下线黑名单依赖 Redis + host: + port: 6379 + timeout: 5000 + rabbitmq: + host: + port: 5672 + username: + password: + servlet: + multipart: + max-file-size: 500MB # 按网盘需求调整 + max-request-size: 500MB + main: + allow-bean-definition-overriding: true + +mybatis-plus: + mapper-locations: classpath*:com/senmesh/**/mapper/*.xml # 必须有,否则自定义 XML 的接口全部报 Invalid bound statement + +system: + code: 1 + +# 发号器雪花 workerId:键格式 code.snowflake.workid.<本机IP> +# 单机部署:直接写死本机 IP 的键;多机部署:每台机器一个键、值必须不同且 0~31 +"[code.snowflake.workid.192.168.1.10]": 1 + +security: + jwt: + enable: true + secret: <上线前必须换成 64 字节以上随机串,勿用文档示例> + expiration: 86400 # token 有效期(秒) + header: Authorization + ignoring: /error,/favicon.ico,/v2/api-docs,/swagger-resources/** + login-page: /login + superior-only: false # true=仅平台机构用户可登录系统;按部署侧实际选择 + use-local: true + allow-if-all-abstain-decisions: true + oauth2: + enable: false + anonymous: "" # 匿名白名单按需追加(逗号分隔,如 /file/upload) + +user: + default: + password: <新用户初始密码> + +file: + upload-dir: /data/upload # 容器/主机上需存在且可写 + storage: + type: local # 阿里云 OSS 则改 ali 并补齐 oss 配置 + transfer: + location: "1" # 内网侧 "1",外网侧 "2" —— 两侧必须相反! + send-dir: /data/transfer/send + receive-dir: /data/transfer/receive + unpack-risk-check: false + +dify: + risk: + rule: + api-key: <真实key或占位> + scan: + api-key: <真实key或占位> # 必填(可为占位),否则启动失败 + +rabbitmq: + custom: + topic-exchange-name: s-file-topic + delayed-exchange-name: s-file-delayed + broadcast-id: s-file-broadcast + +swagger: + enable: false # 生产必须 false +``` + +### 0.3 数据库变更 + +- **已有生产库**:执行 `db/upgrade-20260914-upload-config.sql`(达梦执行「1. MySQL / 达梦 DM8」一节,建 `x_upload_config` 表)。其余本次功能无表结构变更。 +- **全新库**:先执行 `db/init-table.sql`(达梦按 MySQL 兼容模式)+ `db/init-data.sql`(平台、平台机构、admin 账号、文件种类字典)。 +- 执行后验证:`select count(*) from x_upload_config;` 无报错即建表成功(0 行为正常,未配置的机构走默认策略)。 + +### 0.4 目录与权限 + +``` +mkdir -p /data/upload /data/transfer/send /data/transfer/receive +chown -R <运行用户> /data +``` + +外网侧与内网侧通过 `file.transfer.location` 区分,`send-dir`/`receive-dir` 指向各自的摆渡目录(网闸/摆渡盘)。 + +--- + +## 1. 启动方式 + +### 1.1 裸机 / 虚拟机(JDK 8) + +```bash +# 需 JDK 1.8(java -version 确认) +java -Xms1g -Xmx2g \ + -Dspring.config.additional-location=file:/opt/sfile/config/application.yml \ + -jar /opt/sfile/s-file.jar \ + > /opt/sfile/logs/stdout.log 2>&1 & +``` + +多节点时按节点 IP 配置不同的 `code.snowflake.workid.`(可用启动参数 `-DCODE_SNOWFLAKE_WORKID_=` 环境变量方式,`_` 映射 `.`)。 + +### 1.2 Docker + +```bash +# 构建(context 内需 s-file.jar 与 Dockerfile,Dockerfile 在 src/main/resources/) +cp target/s-file.jar / && docker build -t s-file:<版本> + +docker run -d --name sfile-app --restart unless-stopped -p 8080:8080 \ + -v /opt/sfile/config/application.yml:/config/application.yml:ro \ + -v /data/upload:/data/upload \ + -v /data/transfer/send:/data/transfer/send \ + -v /data/transfer/receive:/data/transfer/receive \ + -e SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml \ + -e CODE_SNOWFLAKE_WORKID_<容器IP大写下划线>=1 \ + s-file:<版本> +``` + +> 注意:基础镜像 `freshworks/oraclejdk8` 为 amd64,arm64 主机需 `--platform linux/amd64`;workerId 键里的 IP 用容器固定 IP(自定义网络 `--ip`),容器重建 IP 不变。 + +--- + +## 2. 前端发布 + +```bash +cd senmesh-zc-legacy +bun install +bun run build # 产出 dist/(.env.production 已带 VITE_APP_TITLE 引号修复,规避 Bun env 解析 bug) +``` + +- `dist/` 交由 nginx 托管; +- nginx 需将 `/api` 反代到后端并**去掉 `/api` 前缀**(与 vite dev 代理行为一致): + +```nginx +location /api/ { + proxy_pass http://<后端IP>:8080/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + client_max_body_size 500m; # 与后端 multipart 上限匹配 +} +``` + +- `.env.production` 当前值:`VITE_API_URL=`(空,走同域 /api)、`VITE_INIT_PASSWORD`(前端判断初始密码强制改密用)——如有调整需重新 build。 + +--- + +## 3. 本次版本功能与数据依赖清单(验收用) + +| 功能 | 依赖 | 验收方法 | +|---|---|---| +| 用户启用/禁用/注销(管理员) | DB `uc_user.enabled/status`;**Redis**(禁用踢下线黑名单) | 禁用后该用户已登录 token 立即失效(302/401),重新登录被拒 | +| 机构管理员启停本机构用户 | 无额外依赖 | 机构管理员可启停本机构用户;跨机构返回「只能操作本机构的用户」 | +| 机构账号查询(超管) | 新增只读查询 | 管理后台-机构账号页正常出列表与筛选 | +| 按手机号查上传/发送/接收记录 | 无额外依赖 | 输入手机号出记录;发送/接收方向由部署侧 `file.transfer.location` 决定 | +| 机构上传配置 | 新表 `x_upload_config` | 管理后台-上传配置保存后,该机构网盘上传立即按策略拦截 | +| 禁用踢下线 | Redis key `user:disabled:{userId}` | `redis-cli keys 'user:disabled:*'` 可见禁用标记 | + +--- + +## 4. 上线步骤建议 + +1. 生产库执行 0.3 升级 SQL(执行前备份); +2. 替换真实 spire.pdf jar 并重新打包(0.1),核对 jar 时间戳; +3. 准备 application.yml(0.2 模板),**核对 JWT secret 已更换、dify api-key 已填、swagger.enable=false**; +4. 创建 /data 目录,确定内外网 location; +5. 部署后端(1.1 或 1.2),观察启动日志出现 `Started SFileApplication`; +6. 构建并发布前端(2),nginx reload; +7. 验收第 3 节清单(至少:admin 登录、上传一个非白名单文件被拒、禁用一个测试用户并确认其会话失效、记录查询出数据); +8. 观察日志无 `ERROR`,Redis 中 `user:disabled:*` 与登录缓存键正常。 + +## 5. 回滚 + +- 后端:保留上一版 jar/镜像,停新启旧即可(本次无破坏性 DDL/DML,`x_upload_config` 留存不影响旧版本运行); +- 前端:nginx 指回上一版 dist 目录; +- 数据库:无需回滚(仅新增表)。 diff --git a/scripts/sfile-ctl.sh b/scripts/sfile-ctl.sh new file mode 100755 index 0000000..b1188d5 --- /dev/null +++ b/scripts/sfile-ctl.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# ============================================================================== +# s-file 一键部署/运维脚本(生产环境) +# +# 用法: +# ./sfile-ctl.sh start 启动服务 +# ./sfile-ctl.sh stop 停止服务 +# ./sfile-ctl.sh restart 重启服务 +# ./sfile-ctl.sh status 查看状态 +# ./sfile-ctl.sh deploy 发布新版 jar(自动备份旧版并重启) +# ./sfile-ctl.sh log [行数] 查看启动日志(默认 200 行) +# ./sfile-ctl.sh db-upgrade 在达梦库执行升级 SQL(需配置 disql) +# 上线用: db/upgrade-20260915-release.sql +# +# 部署目录结构(脚本首次 start 时自动创建,配置需手工放置): +# /opt/sfile/ +# ├── s-file.jar 服务 jar +# ├── config/application.yml 生产配置(模板见 docs/生产部署说明.md) +# ├── backup/ 历史版本备份 +# ├── logs/ 日志目录 +# └── data/ 上传/摆渡目录(upload/transfer/send/transfer/receive) +# ============================================================================== +set -euo pipefail + +# ------------------------- 环境变量(按服务器实际修改) ------------------------- +APP_NAME="s-file" +INSTALL_DIR="${SFILE_INSTALL_DIR:-/opt/sfile}" +JAR_FILE="${INSTALL_DIR}/${APP_NAME}.jar" +CONFIG_FILE="${INSTALL_DIR}/config/application.yml" +LOG_DIR="${INSTALL_DIR}/logs" +BACKUP_DIR="${INSTALL_DIR}/backup" +STDOUT_LOG="${LOG_DIR}/stdout.log" +GC_LOG="${LOG_DIR}/gc.log" + +# 运行用户(root 部署时可改为专用低权用户,如 sfile) +RUN_USER="${SFILE_RUN_USER:-$(whoami)}" + +# JVM 参数:按机器内存调整 -Xmx;生产建议 2g 起 +JAVA_OPTS="${SFILE_JAVA_OPTS:--Xms1g -Xmx2g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOG_DIR} -Xloggc:${GC_LOG} -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M}" + +# 健康检查地址与超时 +HEALTH_URL="http://127.0.0.1:8080/user/info" +START_TIMEOUT=120 + +# 达梦 disql(db-upgrade 用;达梦容器则为 docker exec -i <容器> /opt/dmdbms/bin/disql ...) +DISQL_CMD="${SFILE_DISQL:-disql /@:5236}" +# ----------------------------------------------------------------------------- + +GREEN='\033[32m'; RED='\033[31m'; YELLOW='\033[33m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +err() { echo -e "${RED}[ERROR]${NC} $*"; } + +get_pid() { + pgrep -f "java .*${JAR_FILE}" 2>/dev/null || true +} + +require_env() { + [ -f "${JAR_FILE}" ] || { err "jar 不存在: ${JAR_FILE},先用 deploy 发布"; exit 1; } + [ -f "${CONFIG_FILE}" ] || { err "配置不存在: ${CONFIG_FILE}(模板见 docs/生产部署说明.md)"; exit 1; } + # 项目要求 JDK 8 运行(字节码 52.0),防护误用高版本 JDK + if command -v java >/dev/null 2>&1; then + local major + major=$(java -version 2>&1 | head -1 | sed -E 's/.*"([0-9]+)\..*"/\1/') + if [ "${major}" != "1" ] && [ "${major}" != "8" ]; then + err "检测到 java 版本为 ${major},本项目要求 JDK 8(请 export JAVA_HOME 指向 JDK8 并确认 java 命令)" + exit 1 + fi + else + err "未找到 java 命令,请安装 JDK 8 并配置 PATH"; exit 1 + fi +} + +prepare_dirs() { + mkdir -p "${INSTALL_DIR}/config" "${LOG_DIR}" "${BACKUP_DIR}" \ + "${INSTALL_DIR}/data/upload" \ + "${INSTALL_DIR}/data/transfer/send" \ + "${INSTALL_DIR}/data/transfer/receive" +} + +start() { + require_env + local pid + pid=$(get_pid) + if [ -n "${pid}" ]; then + warn "${APP_NAME} 已在运行 (PID ${pid}),如需重启请先 stop" + exit 0 + fi + prepare_dirs + info "启动 ${APP_NAME} ..." + # workerId 说明:多节点部署时每台机器配置不同的 code.snowflake.workid.<本机IP>, + # 最简单的方式是用环境变量(. 映射为 _),如: + # export CODE_SNOWFLAKE_WORKID_192_168_1_10=1 + # shellcheck disable=SC2086 + if [ "${RUN_USER}" = "$(whoami)" ]; then + (cd "${INSTALL_DIR}" && nohup java ${JAVA_OPTS} \ + -Dspring.config.additional-location=file:${CONFIG_FILE} \ + -jar ${JAR_FILE} >> ${STDOUT_LOG} 2>&1 & echo $! > "${INSTALL_DIR}/app.pid") + else + su -s /bin/bash "${RUN_USER}" -c "cd ${INSTALL_DIR} && nohup java ${JAVA_OPTS} \ + -Dspring.config.additional-location=file:${CONFIG_FILE} \ + -jar ${JAR_FILE} >> ${STDOUT_LOG} 2>&1 & echo \$! > ${INSTALL_DIR}/app.pid" + fi + + # 启动健康检查轮询 + info "等待服务就绪(最多 ${START_TIMEOUT}s)..." + local waited=0 + while [ ${waited} -lt ${START_TIMEOUT} ]; do + if curl -sf -o /dev/null --max-time 3 "${HEALTH_URL}"; then + info "启动成功 (PID $(cat "${INSTALL_DIR}/app.pid")),耗时约 ${waited}s" + info "日志: tail -f ${STDOUT_LOG}" + return 0 + fi + # 进程若已退出则立即报错,不干等 + if [ -z "$(get_pid)" ]; then + err "进程已退出,最近日志:" + tail -30 "${STDOUT_LOG}" + exit 1 + fi + sleep 3; waited=$((waited + 3)) + done + err "${START_TIMEOUT}s 内未就绪,请检查日志: tail -100 ${STDOUT_LOG}" + exit 1 +} + +stop() { + local pid + pid=$(get_pid) + if [ -z "${pid}" ]; then + info "${APP_NAME} 未在运行" + return 0 + fi + info "停止 ${APP_NAME} (PID ${pid}) ..." + kill "${pid}" + local waited=0 + while kill -0 "${pid}" 2>/dev/null && [ ${waited} -lt 60 ]; do + sleep 2; waited=$((waited + 2)) + done + if kill -0 "${pid}" 2>/dev/null; then + warn "60s 未退出,强制 kill -9" + kill -9 "${pid}" 2>/dev/null || true + fi + rm -f "${INSTALL_DIR}/app.pid" + info "已停止" +} + +status() { + local pid + pid=$(get_pid) + if [ -z "${pid}" ]; then + info "${APP_NAME}: 未运行" + exit 1 + fi + info "${APP_NAME}: 运行中 (PID ${pid})" + if curl -sf -o /dev/null --max-time 3 "${HEALTH_URL}"; then + info "健康检查: 正常" + else + warn "健康检查: 8080 未响应(可能仍在启动)" + fi + info "内存: $(ps -o rss= -p "${pid}" | awk '{printf "%.0fMB", $1/1024}')" +} + +deploy() { + local new_jar="$1" + [ -f "${new_jar}" ] || { err "找不到 jar: ${new_jar}"; exit 1; } + prepare_dirs + local ts; ts=$(date +%Y%m%d-%H%M%S) + if [ -f "${JAR_FILE}" ]; then + # 运行中先停 + if [ -n "$(get_pid)" ]; then + stop + fi + cp "${JAR_FILE}" "${BACKUP_DIR}/${APP_NAME}-${ts}.jar" + info "旧版本已备份: ${BACKUP_DIR}/${APP_NAME}-${ts}.jar" + fi + cp "${new_jar}" "${JAR_FILE}" + # 保留最近 5 个备份 + ls -1t "${BACKUP_DIR}/${APP_NAME}-"*.jar 2>/dev/null | tail -n +6 | xargs -r rm -f + info "新版本已就位: ${JAR_FILE} ($(du -h "${JAR_FILE}" | cut -f1))" + start +} + +db_upgrade() { + local sql_file="$1" + [ -f "${sql_file}" ] || { err "找不到 SQL: ${sql_file}"; exit 1; } + case "${DISQL_CMD}" in + *""*) err "请先在脚本头部或环境变量 SFILE_DISQL 配置真实 disql 连接串"; exit 1;; + esac + info "执行升级 SQL: ${sql_file}" + # 达梦 disql 执行 SQL 文件语法: disql 用户/密码@host:port \`文件路径\` + ${DISQL_CMD} \`"${sql_file}" + info "执行完成,请核对上方输出无 ERROR 后再继续部署" +} + +log() { + local lines="${1:-200}" + tail -n "${lines}" "${STDOUT_LOG}" 2>/dev/null || warn "暂无日志: ${STDOUT_LOG}" +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + restart) stop; start ;; + status) status ;; + deploy) [ $# -ge 2 ] || { err "用法: $0 deploy "; exit 1; }; deploy "$2" ;; + log) log "${2:-200}" ;; + db-upgrade) [ $# -ge 2 ] || { err "用法: $0 db-upgrade "; exit 1; }; db_upgrade "$2" ;; + *) sed -n '2,20p' "$0"; exit 1 ;; +esac diff --git a/src/main/java/com/senmesh/common/constant/CommonConstants.java b/src/main/java/com/senmesh/common/constant/CommonConstants.java index 980532c..4a2d440 100644 --- a/src/main/java/com/senmesh/common/constant/CommonConstants.java +++ b/src/main/java/com/senmesh/common/constant/CommonConstants.java @@ -63,5 +63,8 @@ public interface CommonConstants { /** 平台机构id **/ Long PLATFORM_ORG_ID = 1L; - + + /** 用户禁用/注销登录态黑名单key前缀(存Redis,值存在期间该用户已发放的token视为无效) **/ + String USER_DISABLED_KEY = "user:disabled:"; + } diff --git a/src/main/java/com/senmesh/security/dto/UserInfoDTO.java b/src/main/java/com/senmesh/security/dto/UserInfoDTO.java index 11990c3..409bae3 100644 --- a/src/main/java/com/senmesh/security/dto/UserInfoDTO.java +++ b/src/main/java/com/senmesh/security/dto/UserInfoDTO.java @@ -6,6 +6,8 @@ import com.senmesh.security.util.PasswordUtils; import com.senmesh.usercenter.user.util.UserUtils; import java.io.Serializable; +import org.apache.commons.lang3.StringUtils; + import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.AllArgsConstructor; @@ -75,7 +77,14 @@ public class UserInfoDTO implements Serializable{ public void setPassword(String password){ this.password = password; - if (!PasswordUtils.match(UserUtils.getDefaultOrgUserPassword(), password)) { + // 未配置默认密码(user.default.password)或密码哈希为空时跳过比对, + // 视为非初始密码,避免缺配置导致登录链路抛 rawPassword cannot be null + String defaultPassword = UserUtils.getDefaultOrgUserPassword(); + if (!StringUtils.isNotBlank(defaultPassword) || !StringUtils.isNotBlank(password)) { + this.originPassword = Boolean.FALSE; + return; + } + if (!PasswordUtils.match(defaultPassword, password)) { this.originPassword = Boolean.FALSE; } } diff --git a/src/main/java/com/senmesh/security/jwt/JwtAuthenticationTokenFilter.java b/src/main/java/com/senmesh/security/jwt/JwtAuthenticationTokenFilter.java index 0fd8c39..961a2e9 100644 --- a/src/main/java/com/senmesh/security/jwt/JwtAuthenticationTokenFilter.java +++ b/src/main/java/com/senmesh/security/jwt/JwtAuthenticationTokenFilter.java @@ -22,6 +22,7 @@ import com.senmesh.common.util.StrUtils; import com.senmesh.common.util.UserContextHolder; import com.senmesh.security.config.SecurityProperties; import com.senmesh.security.util.HostContextHolder; +import com.senmesh.security.util.UserDisabledCache; import com.senmesh.usercenter.constants.OrgConstants; import com.senmesh.usercenter.dto.IUserDetail; @@ -67,9 +68,14 @@ public class JwtAuthenticationTokenFilter extends GenericFilterBean { userDto = JwtTokenUtils.getUserLoginDtoFromToken(cookieToken); } if (userDto != null) { - SecurityContextHolder.getContext() - .setAuthentication(JwtTokenUtils.getAuthentication(userDto)); - UserContextHolder.setContext(userDto); + // 禁用/注销用户的已发放token立即失效(黑名单命中则不建立登录上下文) + if (UserDisabledCache.isMarked(userDto.getId())) { + log.debug("user is disabled or canceled, token rejected, userId:{}", userDto.getId()); + } else { + SecurityContextHolder.getContext() + .setAuthentication(JwtTokenUtils.getAuthentication(userDto)); + UserContextHolder.setContext(userDto); + } } } diff --git a/src/main/java/com/senmesh/security/util/UserDisabledCache.java b/src/main/java/com/senmesh/security/util/UserDisabledCache.java new file mode 100644 index 0000000..348d173 --- /dev/null +++ b/src/main/java/com/senmesh/security/util/UserDisabledCache.java @@ -0,0 +1,64 @@ +package com.senmesh.security.util; + +import com.senmesh.common.constant.CommonConstants; +import com.senmesh.frame.redis.redisson.RedissonUtils; + +import lombok.extern.slf4j.Slf4j; + +/** + * 用户禁用/注销登录态黑名单 + * 用户被禁用或注销后,其已发放且未过期的token需要立即失效: + * 写入黑名单标记后,JwtAuthenticationTokenFilter将对命中标记的用户不再建立登录上下文, + * 标记TTL与token有效期一致,token自然过期后标记自动清理 + * + * @author liu on 2026-09-14 + */ +@Slf4j +public class UserDisabledCache { + + private UserDisabledCache() { + } + + private static String key(Long userId) { + return CommonConstants.USER_DISABLED_KEY + userId; + } + + /** + * 标记用户登录态失效 + * + * @param userId 用户id + * @param ttlSeconds 存活秒数(取token有效期) + */ + public static void mark(Long userId, long ttlSeconds) { + try { + RedissonUtils.getClient().getBucket(key(userId)).set(Boolean.TRUE, ttlSeconds, java.util.concurrent.TimeUnit.SECONDS); + } catch (Exception e) { + log.error("标记用户登录态失效失败,userId:{}", userId, e); + } + } + + /** + * 解除标记(启用用户) + */ + public static void unmark(Long userId) { + try { + RedissonUtils.getClient().getBucket(key(userId)).delete(); + } catch (Exception e) { + log.error("解除用户登录态标记失败,userId:{}", userId, e); + } + } + + /** + * 是否被标记(禁用/注销) + * Redis异常时降级放行,由数据库状态在后续链路兜底 + */ + public static boolean isMarked(Long userId) { + try { + return Boolean.TRUE.equals(RedissonUtils.getClient().getBucket(key(userId)).get()); + } catch (Exception e) { + log.warn("查询用户登录态标记异常,降级放行,userId:{}", userId, e); + return false; + } + } + +} diff --git a/src/main/java/com/senmesh/sfile/constant/FileRecordTypeEnum.java b/src/main/java/com/senmesh/sfile/constant/FileRecordTypeEnum.java new file mode 100644 index 0000000..645c999 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/constant/FileRecordTypeEnum.java @@ -0,0 +1,53 @@ +package com.senmesh.sfile.constant; + +/** + * 文件记录类型枚举(按手机号查询上传/发送/接收记录) + */ +public enum FileRecordTypeEnum { + + /** + * 上传记录 + */ + UPLOAD(1, "上传记录"), + + /** + * 发送记录(本端发起文件传输) + */ + SEND(2, "发送记录"), + + /** + * 接收记录(本端接收文件传输) + */ + RECEIVE(3, "接收记录"); + + private final Integer type; + private final String desc; + + FileRecordTypeEnum(Integer type, String desc) { + this.type = type; + this.desc = desc; + } + + public Integer getType() { + return type; + } + + public String getDesc() { + return desc; + } + + /** + * 根据类型获取枚举 + * + * @param type 类型 + * @return FileRecordTypeEnum + */ + public static FileRecordTypeEnum getByType(Integer type) { + for (FileRecordTypeEnum recordTypeEnum : FileRecordTypeEnum.values()) { + if (recordTypeEnum.getType().equals(type)) { + return recordTypeEnum; + } + } + return null; + } +} diff --git a/src/main/java/com/senmesh/sfile/controller/UploadConfigController.java b/src/main/java/com/senmesh/sfile/controller/UploadConfigController.java new file mode 100644 index 0000000..384f371 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/controller/UploadConfigController.java @@ -0,0 +1,52 @@ +package com.senmesh.sfile.controller; + +import com.senmesh.common.AlitaResult; +import com.senmesh.sfile.dto.UploadConfigDTO; +import com.senmesh.sfile.dto.UploadConfigModifyDTO; +import com.senmesh.sfile.service.UploadConfigService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 机构上传配置Controller + * 机构管理员可配置本机构允许上传的文件类型/大小等,配置对网盘上传生效 + * + * @author liu on 2026-09-14 + */ +@Api(tags = "机构上传配置") +@RestController +@RequestMapping("upload_config") +public class UploadConfigController { + + @Autowired + private UploadConfigService uploadConfigService; + + @GetMapping("/info") + @ApiOperation("查询当前登录机构的上传配置(未配置返回默认策略)") + public AlitaResult info() { + return AlitaResult.success(uploadConfigService.info()); + } + + @PostMapping("/modify") + @ApiOperation("保存当前登录机构的上传配置(机构管理员/平台超管)") + public AlitaResult modify(@Validated @RequestBody UploadConfigModifyDTO modifyDTO) { + return AlitaResult.success(uploadConfigService.modify(modifyDTO)); + } + + @GetMapping("/check") + @ApiOperation("上传预检(按文件名校验当前机构配置,不通过返回业务异常)") + public AlitaResult check(@RequestParam("fileName") String fileName, + @RequestParam(value = "fileSize", required = false) Long fileSize) { + uploadConfigService.check(fileName, fileSize); + return AlitaResult.success(true); + } + +} diff --git a/src/main/java/com/senmesh/sfile/controller/UserFileController.java b/src/main/java/com/senmesh/sfile/controller/UserFileController.java index e0ca5ba..de23afc 100644 --- a/src/main/java/com/senmesh/sfile/controller/UserFileController.java +++ b/src/main/java/com/senmesh/sfile/controller/UserFileController.java @@ -8,6 +8,8 @@ import com.senmesh.sfile.dto.UserFileDTO; import com.senmesh.sfile.dto.UserFileModifyDTO; import com.senmesh.sfile.dto.UserFileModifyStatusDTO; import com.senmesh.sfile.dto.UserFileQueryDTO; +import com.senmesh.sfile.dto.UserFileRecordDTO; +import com.senmesh.sfile.dto.UserFileRecordQueryDTO; import com.senmesh.sfile.dto.UserFileStatisticsDTO; import com.senmesh.sfile.service.UserFileService; import com.senmesh.tkmybatis.pojo.Page; @@ -115,6 +117,13 @@ public class UserFileController { return AlitaResult.success(userFileService.statisticsByCategory(queryDTO)); } + @GetMapping("/record/page_by_phone") + @ApiOperation("按手机号分页查询文件记录(上传/发送/接收,系统管理员)") + public AlitaResult> pageRecordByPhone(UserFileRecordQueryDTO queryDTO) { + userFileService.pageRecordByPhone(queryDTO); + return AlitaResult.success(queryDTO.getPageResult()); + } + } diff --git a/src/main/java/com/senmesh/sfile/dao/UploadConfigMapper.java b/src/main/java/com/senmesh/sfile/dao/UploadConfigMapper.java new file mode 100644 index 0000000..726d824 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/dao/UploadConfigMapper.java @@ -0,0 +1,15 @@ +package com.senmesh.sfile.dao; + +import com.senmesh.sfile.entity.UploadConfig; +import com.senmesh.tkmybatis.mybatis.MyMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * 机构上传配置Mapper + * + * @author liu on 2026-09-14 + */ +@Mapper +public interface UploadConfigMapper extends MyMapper { + +} diff --git a/src/main/java/com/senmesh/sfile/dao/UserFileMapper.java b/src/main/java/com/senmesh/sfile/dao/UserFileMapper.java index d8d4dad..e4e30cd 100644 --- a/src/main/java/com/senmesh/sfile/dao/UserFileMapper.java +++ b/src/main/java/com/senmesh/sfile/dao/UserFileMapper.java @@ -2,6 +2,8 @@ package com.senmesh.sfile.dao; import com.senmesh.sfile.dto.UserFileDTO; import com.senmesh.sfile.dto.UserFileQueryDTO; +import com.senmesh.sfile.dto.UserFileRecordDTO; +import com.senmesh.sfile.dto.UserFileRecordQueryDTO; import com.senmesh.sfile.dto.UserFileStatisticsDTO; import com.senmesh.sfile.entity.UserFile; import com.senmesh.tkmybatis.mybatis.MyMapper; @@ -64,5 +66,13 @@ public interface UserFileMapper extends MyMapper { * @return List */ List statisticsByCategory(@Param("param") UserFileQueryDTO queryDTO); + + /** + * 按手机号查询文件记录(上传/发送/接收,PageHelper会根据queryDTO自动判断是否分页) + * + * @param queryDTO 查询DTO + * @return List + */ + List listRecordByPhone(@Param("param") UserFileRecordQueryDTO queryDTO); } diff --git a/src/main/java/com/senmesh/sfile/dto/UploadConfigDTO.java b/src/main/java/com/senmesh/sfile/dto/UploadConfigDTO.java new file mode 100644 index 0000000..a22a4dc --- /dev/null +++ b/src/main/java/com/senmesh/sfile/dto/UploadConfigDTO.java @@ -0,0 +1,46 @@ +package com.senmesh.sfile.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +/** + * 机构上传配置DTO + * + * @author liu on 2026-09-14 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@ApiModel("机构上传配置") +public class UploadConfigDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + @ApiModelProperty("机构id") + private Long orgId; + + @ApiModelProperty("是否允许上传") + private Boolean uploadEnabled; + + @ApiModelProperty("是否允许全部类型") + private Boolean allowAllType; + + @ApiModelProperty("允许的文件后缀(小写,不含点),allow_all_type=false时生效") + private List allowedSuffixes; + + @ApiModelProperty("单文件大小上限(MB),空或0不限制") + private Long maxFileSize; + + @ApiModelProperty("机构是否已配置过(false表示使用默认策略:允许上传全部类型)") + private Boolean configured; + +} diff --git a/src/main/java/com/senmesh/sfile/dto/UploadConfigModifyDTO.java b/src/main/java/com/senmesh/sfile/dto/UploadConfigModifyDTO.java new file mode 100644 index 0000000..4d33dd3 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/dto/UploadConfigModifyDTO.java @@ -0,0 +1,44 @@ +package com.senmesh.sfile.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +import javax.validation.constraints.NotNull; + +/** + * 机构上传配置修改DTO + * + * @author liu on 2026-09-14 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@ApiModel("机构上传配置修改") +public class UploadConfigModifyDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + @ApiModelProperty("是否允许上传") + @NotNull(message = "是否允许上传不能为空") + private Boolean uploadEnabled; + + @ApiModelProperty("是否允许全部类型") + @NotNull(message = "是否允许全部类型不能为空") + private Boolean allowAllType; + + @ApiModelProperty("允许的文件后缀(不含点,自动忽略大小写),allow_all_type=false时必填") + private List allowedSuffixes; + + @ApiModelProperty("单文件大小上限(MB),空或0不限制") + private Long maxFileSize; + +} diff --git a/src/main/java/com/senmesh/sfile/dto/UserFileRecordDTO.java b/src/main/java/com/senmesh/sfile/dto/UserFileRecordDTO.java new file mode 100644 index 0000000..560a65f --- /dev/null +++ b/src/main/java/com/senmesh/sfile/dto/UserFileRecordDTO.java @@ -0,0 +1,43 @@ +package com.senmesh.sfile.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +/** + * 用户文件记录DTO(按手机号查询上传/发送/接收记录) + */ +@ApiModel("用户文件记录") +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class UserFileRecordDTO extends UserFileDTO { + + private static final long serialVersionUID = 1L; + + @ApiModelProperty("手机号") + private String phone; + + @ApiModelProperty("账号") + private String username; + + @ApiModelProperty("真实姓名") + private String realName; + + @ApiModelProperty("用户类型:1企业用户,2个人用户") + private Integer userType; + + @ApiModelProperty("记录类型:1上传记录/2发送记录/3接收记录") + private Integer recordType; + + @ApiModelProperty("记录类型描述") + private String recordTypeDesc; + +} diff --git a/src/main/java/com/senmesh/sfile/dto/UserFileRecordQueryDTO.java b/src/main/java/com/senmesh/sfile/dto/UserFileRecordQueryDTO.java new file mode 100644 index 0000000..af24ccf --- /dev/null +++ b/src/main/java/com/senmesh/sfile/dto/UserFileRecordQueryDTO.java @@ -0,0 +1,70 @@ +package com.senmesh.sfile.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.senmesh.common.dto.PageQueryDTO; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; +import java.util.Set; + +/** + * 用户文件记录查询DTO(按手机号查询上传/发送/接收记录) + */ +@ApiModel("用户文件记录查询DTO(按手机号)") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class UserFileRecordQueryDTO extends PageQueryDTO { + + private static final long serialVersionUID = 1L; + + @ApiModelProperty(value = "手机号(必填,精确匹配)", required = true) + private String phone; + + @ApiModelProperty("记录类型:1上传记录/2发送记录/3接收记录(不传查全部)") + private Integer recordType; + + @ApiModelProperty("文件名(模糊查询)") + private String fileNameLike; + + @ApiModelProperty("文件状态:1正常2失效11解析中12待审核13传输中14失败") + private String fileStatus; + + @ApiModelProperty("文件类型:1文件/2文件夹") + private String fileType; + + @ApiModelProperty("创建时间起") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "GMT+8" + ) + private Date createTimeBegin; + + @ApiModelProperty("创建时间止") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "GMT+8" + ) + private Date createTimeEnd; + + @ApiModelProperty(hidden = true) + @JsonIgnore + private Set rootTypeIn; + +} diff --git a/src/main/java/com/senmesh/sfile/entity/UploadConfig.java b/src/main/java/com/senmesh/sfile/entity/UploadConfig.java new file mode 100644 index 0000000..0b58c05 --- /dev/null +++ b/src/main/java/com/senmesh/sfile/entity/UploadConfig.java @@ -0,0 +1,43 @@ +package com.senmesh.sfile.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.senmesh.common.entity.BaseEntity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +/** + * 机构上传配置 + * 一行对应一个机构,用于限制该机构用户在网盘中可上传的文件类型/大小 + * + * @author liu on 2026-09-14 + */ +@TableName("x_upload_config") +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@Data +public class UploadConfig extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 机构id(唯一) */ + private Long orgId; + + /** 是否允许上传 */ + private Boolean uploadEnabled; + + /** 是否允许全部类型(false时按allowed_suffixes白名单校验) */ + private Boolean allowAllType; + + /** 允许的文件后缀(逗号分隔,小写存储),allow_all_type=false时生效 */ + private String allowedSuffixes; + + /** 单文件大小上限(MB),空或0不限制 */ + private Long maxFileSize; + +} diff --git a/src/main/java/com/senmesh/sfile/mapper/UserFileMapper.xml b/src/main/java/com/senmesh/sfile/mapper/UserFileMapper.xml index f89b785..c07139b 100644 --- a/src/main/java/com/senmesh/sfile/mapper/UserFileMapper.xml +++ b/src/main/java/com/senmesh/sfile/mapper/UserFileMapper.xml @@ -9,6 +9,44 @@ ORDER BY t.id DESC + + + SELECT + ou.org_id, + o.org_name, + ou.administrator, + u.id AS user_id, + u.username, + u.nickname, + u.real_name, + u.phone, + u.email, + u.type AS user_type, + u.enabled, + u.status, + u.create_time + FROM uc_org_user ou + INNER JOIN uc_user u ON u.id = ou.user_id AND u.deleted = FALSE AND u.type != 3 + LEFT JOIN xs_org o ON o.id = ou.org_id AND o.deleted = FALSE + WHERE ou.deleted = FALSE + + AND ou.org_id = #{param.orgId} + + + + AND o.org_name LIKE #{orgNameLikeBind} + + + + AND u.username LIKE #{usernameLikeBind} + + + + AND u.real_name LIKE #{realNameLikeBind} + + + + AND u.phone LIKE #{phoneLikeBind} + + + AND u.enabled = #{param.enabled} + + + AND u.status = #{param.status} + + + AND u.create_time >= #{param.createTimeStart} + + + AND u.create_time <= #{param.createTimeEnd} + + ORDER BY ou.org_id ASC, CASE WHEN ou.administrator THEN 1 ELSE 0 END DESC, u.id DESC + + diff --git a/src/main/java/com/senmesh/usercenter/user/service/UserService.java b/src/main/java/com/senmesh/usercenter/user/service/UserService.java index ba46db2..510d803 100644 --- a/src/main/java/com/senmesh/usercenter/user/service/UserService.java +++ b/src/main/java/com/senmesh/usercenter/user/service/UserService.java @@ -335,4 +335,35 @@ public interface UserService { */ List listByRealNameOrPhone(Set realNames, Set phones); + /** + * 启用用户(系统管理员) + * + * @param reqDTO 操作dto + * @return {@link Boolean} + */ + Boolean enableUser(UserStatusOperateDTO reqDTO); + + /** + * 禁用用户(系统管理员) + * + * @param reqDTO 操作dto + * @return {@link Boolean} + */ + Boolean disableUser(UserStatusOperateDTO reqDTO); + + /** + * 注销用户(系统管理员,直接注销不进入注销申请流程) + * + * @param reqDTO 操作dto + */ + void adminCancelUser(UserStatusOperateDTO reqDTO); + + /** + * 分页查询机构账号信息(系统管理员) + * 一行代表一个机构下的一个账号 + * + * @param queryDTO 查询dto + */ + void pageOrgAccount(OrgAccountQueryDTO queryDTO); + } diff --git a/src/main/java/com/senmesh/usercenter/user/service/impl/UserServiceImpl.java b/src/main/java/com/senmesh/usercenter/user/service/impl/UserServiceImpl.java index dbf5b6d..636393e 100644 --- a/src/main/java/com/senmesh/usercenter/user/service/impl/UserServiceImpl.java +++ b/src/main/java/com/senmesh/usercenter/user/service/impl/UserServiceImpl.java @@ -35,6 +35,7 @@ import com.senmesh.mq.util.MqSendUtils; import com.senmesh.security.dto.UserInfoDTO; import com.senmesh.security.jwt.JwtTokenUtils; import com.senmesh.security.util.PasswordUtils; +import com.senmesh.security.util.UserDisabledCache; import com.senmesh.usercenter.auth.constant.AuthTypeEnum; import com.senmesh.usercenter.auth.dto.AuthDetailBindDTO; import com.senmesh.usercenter.auth.dto.AuthDetailLoginDTO; @@ -53,6 +54,8 @@ import com.senmesh.usercenter.user.dto.ChangePhoneMqDTO; import com.senmesh.usercenter.user.dto.CheckSecurityCodeReqDTO; import com.senmesh.usercenter.user.dto.InitUserPasswordDTO; import com.senmesh.usercenter.user.dto.ModifyPasswordDTO; +import com.senmesh.usercenter.user.dto.OrgAccountDTO; +import com.senmesh.usercenter.user.dto.OrgAccountQueryDTO; import com.senmesh.usercenter.user.dto.OrgUserCreateDTO; import com.senmesh.usercenter.user.dto.OrgUserModifyDTO; import com.senmesh.usercenter.user.dto.OrgUserOperateDTO; @@ -78,6 +81,7 @@ import com.senmesh.usercenter.user.dto.UserExtensionOperateDTO; import com.senmesh.usercenter.user.dto.UserOperateDTO; import com.senmesh.usercenter.user.dto.UserQueryByRoleDTO; import com.senmesh.usercenter.user.dto.UserQueryDTO; +import com.senmesh.usercenter.user.dto.UserStatusOperateDTO; import com.senmesh.usercenter.user.entity.User; import com.senmesh.usercenter.user.login.dto.UserDetailDTO; import com.senmesh.usercenter.user.mapstruct.UserMapMapper; @@ -498,6 +502,8 @@ public class UserServiceImpl implements UserService { updateInfo.setStatus(UserStatusEnum.CLOSED.getStatus()); updateInfo.setEnabled(Boolean.FALSE); userMapper.updateEntitySelective(updateInfo, UserContextHolder.getLoginUserId()); + // 使该用户已发放的token立即失效 + UserDisabledCache.mark(userDTO.getId(), JwtTokenUtils.getExpiration()); } } @@ -991,4 +997,93 @@ public class UserServiceImpl implements UserService { } return userMapper.listByRealNameOrPhone(UserContextHolder.getExistLoginOrgId(), realNames, phones); } + + @Override + @Transactional + public Boolean enableUser(UserStatusOperateDTO reqDTO) { + // 平台超管可操作全部用户,机构管理员仅可启用本机构用户 + Assert.isTrue(UserContextHolder.isSuperior() || UserContextHolder.isAdministrator(), + "当前用户无操作权限"); + this.checkOrgScopeOfOperateUser(reqDTO.getUserId()); + User user = userMapper.getById(reqDTO.getUserId()); + Assert.notNull(user, "用户不存在"); + Assert.isTrue(!UserStatusEnum.CLOSED.getStatus().equals(user.getStatus()), "已注销账号不可启用"); + // 幂等:已启用直接返回 + if (Boolean.TRUE.equals(user.getEnabled())) { + return true; + } + User updateInfo = User.builder().id(user.getId()).enabled(Boolean.TRUE).build(); + boolean updated = userMapper.updateEntitySelective(updateInfo, UserContextHolder.getLoginUserId()) == 1; + if (updated) { + UserDisabledCache.unmark(user.getId()); + } + return updated; + } + + @Override + @Transactional + public Boolean disableUser(UserStatusOperateDTO reqDTO) { + // 平台超管可操作全部用户,机构管理员仅可禁用本机构用户 + Assert.isTrue(UserContextHolder.isSuperior() || UserContextHolder.isAdministrator(), + "当前用户无操作权限"); + Long operateUserId = UserContextHolder.getExistLoginUserId(); + Long userId = reqDTO.getUserId(); + Assert.isTrue(!userId.equals(operateUserId), "不能禁用当前登录账号"); + this.checkOrgScopeOfOperateUser(userId); + User user = userMapper.getById(userId); + Assert.notNull(user, "用户不存在"); + Assert.isTrue(!Boolean.TRUE.equals(user.getSupeiror()), "不能禁用超级管理员账号"); + Assert.isTrue(!UserStatusEnum.CLOSED.getStatus().equals(user.getStatus()), "已注销账号无需禁用"); + // 幂等:已禁用直接返回 + if (Boolean.FALSE.equals(user.getEnabled())) { + return true; + } + User updateInfo = User.builder().id(user.getId()).enabled(Boolean.FALSE).build(); + boolean updated = userMapper.updateEntitySelective(updateInfo, operateUserId) == 1; + if (updated) { + // 使该用户已发放的token立即失效 + UserDisabledCache.mark(user.getId(), JwtTokenUtils.getExpiration()); + } + return updated; + } + + /** + * 非平台超管(机构管理员)操作他人账号时,校验目标用户属于本机构 + */ + private void checkOrgScopeOfOperateUser(Long userId) { + if (!UserContextHolder.isSuperior()) { + Assert.isTrue( + orgUserService.existOrgUser(UserContextHolder.getExistLoginOrgId(), userId), + "只能操作本机构的用户"); + } + } + + @Override + @AutoUnLock + @Transactional(rollbackFor = Exception.class) + public void adminCancelUser(UserStatusOperateDTO reqDTO) { + Assert.isTrue(UserContextHolder.isSuperior(), "当前用户无操作权限"); + Long operateUserId = UserContextHolder.getExistLoginUserId(); + Long userId = reqDTO.getUserId(); + Assert.isTrue(!userId.equals(operateUserId), "不能注销当前登录账号"); + Assert.isTrue(RedissonUtils.tryLockAutomatically( + LockDTO.builder().methodName("adminCancelUser").arg(String.valueOf(userId)).build()), + "该用户正在注销,请勿重复操作"); + User user = userMapper.getById(userId); + Assert.notNull(user, "用户不存在"); + Assert.isTrue(!Boolean.TRUE.equals(user.getSupeiror()), "不能注销超级管理员账号"); + // 幂等:已注销直接返回 + if (UserStatusEnum.CLOSED.getStatus().equals(user.getStatus())) { + return; + } + Assert.isTrue(userMapper.adminCancel(userId, operateUserId) == 1, "注销失败,请刷新后重试"); + // 使该用户已发放的token立即失效 + UserDisabledCache.mark(userId, JwtTokenUtils.getExpiration()); + } + + @Override + public void pageOrgAccount(OrgAccountQueryDTO queryDTO) { + Assert.isTrue(UserContextHolder.isSuperior(), "当前用户无操作权限"); + userMapper.listOrgAccount(queryDTO); + } }