diff --git a/sql/dm/doc_management.sql b/sql/dm/doc_management.sql
new file mode 100644
index 00000000..5dbde549
--- /dev/null
+++ b/sql/dm/doc_management.sql
@@ -0,0 +1,144 @@
+/*
+ Yudao Database Transfer Tool
+
+ Source Server Type : MySQL
+
+ Target Server Type : DM8
+
+ Date: 2025-08-29 10:32:27
+*/
+
+
+-- ----------------------------
+-- Table structure for infra_doc_file
+-- ----------------------------
+CREATE TABLE infra_doc_file (
+ id bigint NOT NULL PRIMARY KEY,
+ title varchar(255) NOT NULL,
+ file_id bigint DEFAULT NULL NULL,
+ file_type varchar(10) NOT NULL,
+ space_type smallint DEFAULT '1' NOT NULL,
+ description varchar(500) DEFAULT NULL NULL,
+ latest_version_id bigint DEFAULT NULL NULL,
+ owner_user_id bigint NOT NULL,
+ status smallint DEFAULT '1' NOT NULL,
+ creator varchar(64) DEFAULT '' NULL,
+ create_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updater varchar(64) DEFAULT '' NULL,
+ update_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ deleted bit DEFAULT '0' NOT NULL,
+ tenant_id bigint DEFAULT '0' NOT NULL
+);
+
+CREATE INDEX idx_infra_doc_file_01 ON infra_doc_file (owner_user_id);
+CREATE INDEX idx_infra_doc_file_02 ON infra_doc_file (space_type);
+CREATE INDEX idx_infra_doc_file_03 ON infra_doc_file (file_type);
+
+COMMENT ON COLUMN infra_doc_file.id IS '文档编号';
+COMMENT ON COLUMN infra_doc_file.title IS '文档标题';
+COMMENT ON COLUMN infra_doc_file.file_id IS '文件编号';
+COMMENT ON COLUMN infra_doc_file.file_type IS '文件类型(docx/xlsx/pptx/pdf)';
+COMMENT ON COLUMN infra_doc_file.space_type IS '空间类型(1-个人空间 2-团队空间)';
+COMMENT ON COLUMN infra_doc_file.description IS '文档描述';
+COMMENT ON COLUMN infra_doc_file.latest_version_id IS '最新版本编号';
+COMMENT ON COLUMN infra_doc_file.owner_user_id IS '所有者用户编号';
+COMMENT ON COLUMN infra_doc_file.status IS '状态(0-禁用 1-启用)';
+COMMENT ON COLUMN infra_doc_file.creator IS '创建者';
+COMMENT ON COLUMN infra_doc_file.create_time IS '创建时间';
+COMMENT ON COLUMN infra_doc_file.updater IS '更新者';
+COMMENT ON COLUMN infra_doc_file.update_time IS '更新时间';
+COMMENT ON COLUMN infra_doc_file.deleted IS '是否删除';
+COMMENT ON COLUMN infra_doc_file.tenant_id IS '租户编号';
+COMMENT ON TABLE infra_doc_file IS '在线文档表';
+
+-- ----------------------------
+-- Table structure for infra_doc_file_version
+-- ----------------------------
+CREATE TABLE infra_doc_file_version (
+ id bigint NOT NULL PRIMARY KEY,
+ doc_file_id bigint NOT NULL,
+ version_no varchar(50) NOT NULL,
+ file_id bigint NOT NULL,
+ change_description varchar(500) DEFAULT NULL NULL,
+ creator varchar(64) DEFAULT '' NULL,
+ create_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updater varchar(64) DEFAULT '' NULL,
+ update_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ deleted bit DEFAULT '0' NOT NULL,
+ tenant_id bigint DEFAULT '0' NOT NULL
+);
+
+CREATE INDEX idx_infra_doc_file_version_01 ON infra_doc_file_version (doc_file_id);
+CREATE INDEX idx_infra_doc_file_version_02 ON infra_doc_file_version (version_no);
+
+COMMENT ON COLUMN infra_doc_file_version.id IS '版本编号';
+COMMENT ON COLUMN infra_doc_file_version.doc_file_id IS '文档编号';
+COMMENT ON COLUMN infra_doc_file_version.version_no IS '版本号';
+COMMENT ON COLUMN infra_doc_file_version.file_id IS '文件编号';
+COMMENT ON COLUMN infra_doc_file_version.change_description IS '变更说明';
+COMMENT ON COLUMN infra_doc_file_version.creator IS '创建者';
+COMMENT ON COLUMN infra_doc_file_version.create_time IS '创建时间';
+COMMENT ON COLUMN infra_doc_file_version.updater IS '更新者';
+COMMENT ON COLUMN infra_doc_file_version.update_time IS '更新时间';
+COMMENT ON COLUMN infra_doc_file_version.deleted IS '是否删除';
+COMMENT ON COLUMN infra_doc_file_version.tenant_id IS '租户编号';
+COMMENT ON TABLE infra_doc_file_version IS '文档版本表';
+
+-- ----------------------------
+-- Table structure for infra_doc_file_permission
+-- ----------------------------
+CREATE TABLE infra_doc_file_permission (
+ id bigint NOT NULL PRIMARY KEY,
+ doc_file_id bigint NOT NULL,
+ role_id bigint NOT NULL,
+ permission_type smallint DEFAULT '1' NOT NULL,
+ expire_time datetime DEFAULT NULL NULL,
+ creator varchar(64) DEFAULT '' NULL,
+ create_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updater varchar(64) DEFAULT '' NULL,
+ update_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ deleted bit DEFAULT '0' NOT NULL,
+ tenant_id bigint DEFAULT '0' NOT NULL
+);
+
+CREATE INDEX idx_infra_doc_file_permission_01 ON infra_doc_file_permission (role_id);
+
+COMMENT ON COLUMN infra_doc_file_permission.id IS '权限编号';
+COMMENT ON COLUMN infra_doc_file_permission.doc_file_id IS '文档编号';
+COMMENT ON COLUMN infra_doc_file_permission.role_id IS '角色编号';
+COMMENT ON COLUMN infra_doc_file_permission.permission_type IS '权限类型(1-只读 2-编辑 3-管理)';
+COMMENT ON COLUMN infra_doc_file_permission.expire_time IS '过期时间';
+COMMENT ON COLUMN infra_doc_file_permission.creator IS '创建者';
+COMMENT ON COLUMN infra_doc_file_permission.create_time IS '创建时间';
+COMMENT ON COLUMN infra_doc_file_permission.updater IS '更新者';
+COMMENT ON COLUMN infra_doc_file_permission.update_time IS '更新时间';
+COMMENT ON COLUMN infra_doc_file_permission.deleted IS '是否删除';
+COMMENT ON COLUMN infra_doc_file_permission.tenant_id IS '租户编号';
+COMMENT ON TABLE infra_doc_file_permission IS '文档权限表';
+
+-- ----------------------------
+-- Table structure for infra_doc_edit_history
+-- ----------------------------
+CREATE TABLE infra_doc_edit_history (
+ id bigint NOT NULL PRIMARY KEY,
+ doc_file_id bigint NOT NULL,
+ user_id bigint NOT NULL,
+ user_name varchar(100) NOT NULL,
+ edit_type smallint DEFAULT '1' NOT NULL,
+ description varchar(500) DEFAULT NULL NULL,
+ create_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ tenant_id bigint DEFAULT '0' NOT NULL
+);
+
+CREATE INDEX idx_infra_doc_edit_history_01 ON infra_doc_edit_history (doc_file_id);
+CREATE INDEX idx_infra_doc_edit_history_02 ON infra_doc_edit_history (user_id);
+
+COMMENT ON COLUMN infra_doc_edit_history.id IS '历史编号';
+COMMENT ON COLUMN infra_doc_edit_history.doc_file_id IS '文档编号';
+COMMENT ON COLUMN infra_doc_edit_history.user_id IS '编辑用户编号';
+COMMENT ON COLUMN infra_doc_edit_history.user_name IS '编辑用户名称';
+COMMENT ON COLUMN infra_doc_edit_history.edit_type IS '编辑类型(1-创建 2-编辑 3-删除 4-重命名)';
+COMMENT ON COLUMN infra_doc_edit_history.description IS '操作描述';
+COMMENT ON COLUMN infra_doc_edit_history.create_time IS '创建时间';
+COMMENT ON COLUMN infra_doc_edit_history.tenant_id IS '租户编号';
+COMMENT ON TABLE infra_doc_edit_history IS '文档编辑历史表';
diff --git a/sql/dm/doc_management_menu.sql b/sql/dm/doc_management_menu.sql
new file mode 100644
index 00000000..ec698c8b
--- /dev/null
+++ b/sql/dm/doc_management_menu.sql
@@ -0,0 +1,39 @@
+-- 在线文档管理功能菜单数据(DM 格式)
+-- 说明:达梦脚本使用与 patch.sql 相同的精简列形式,省略布尔列与时间列,使用默认值。
+
+-- 清理旧数据,保持可重复执行
+DELETE FROM system_menu WHERE id IN (6000,6001,6002,600101,600102,600103,600104,600105,600106,600107,600108,600109,600110);
+
+-- 顶级目录(父级假定已存在 id=2 的“基础设施/Infra”或同级目录)
+INSERT INTO system_menu (
+ id, name, permission, type, sort, parent_id,
+ path, icon, component, status, component_name
+) VALUES (
+ 6000, '在线文档', '', 1, 15, 2,
+ 'doc', 'fa:file-text-o', '', 0, NULL
+);
+
+-- 文档管理主页面
+INSERT INTO system_menu (
+ id, name, permission, type, sort, parent_id,
+ path, icon, component, status, component_name
+) VALUES (
+ 6001, '文档管理', 'infra:doc:query', 2, 1, 6000,
+ 'doc-file', 'fa:file-text', 'infra/doc/index', 0, 'DocFile'
+);
+
+-- 按钮权限(操作项)
+INSERT INTO system_menu (
+ id, name, permission, type, sort, parent_id,
+ path, icon, component, status
+) VALUES
+ (600101,'文档查询','infra:doc:query',3,1,6001,'','','',0),
+ (600102,'文档创建','infra:doc:create',3,2,6001,'','','',0),
+ (600103,'文档更新','infra:doc:update',3,3,6001,'','','',0),
+ (600104,'文档删除','infra:doc:delete',3,4,6001,'','','',0),
+ (600105,'文档导出','infra:doc:export',3,5,6001,'','','',0),
+ (600106,'文档上传','infra:doc:upload',3,6,6001,'','','',0),
+ (600107,'文档编辑','infra:doc:edit',3,7,6001,'','','',0),
+ (600108,'文档预览','infra:doc:preview',3,8,6001,'','','',0),
+ (600109,'文档下载','infra:doc:download',3,9,6001,'','','',0),
+ (600110,'权限管理','infra:doc:permission',3,10,6001,'','','',0);
diff --git a/sql/dm/patch.sql b/sql/dm/patch.sql
index d97f7849..2a130011 100644
--- a/sql/dm/patch.sql
+++ b/sql/dm/patch.sql
@@ -155,7 +155,7 @@ INSERT INTO system_menu(
path, icon, component, status, component_name
)
VALUES (
- '1953701540574969857', '系统序列号管理', '', 2, 0, ${table.parentMenuId},
+ '1953701540574969857', '系统序列号管理', '', 2, 0, 1,
'sequence', '', 'system/sequence/index', 0, 'Sequence'
);
@@ -259,4 +259,4 @@ VALUES
(5021, 1, '固定值', 'FIXED', 'system_sequence_detail_rule_type', 0, 'primary', '', '固定字符串值', 'admin', SYSDATE, 'admin', SYSDATE, 0),
(5022, 2, '日期格式', 'DATE', 'system_sequence_detail_rule_type', 0, 'success', '', '日期格式规则', 'admin', SYSDATE, 'admin', SYSDATE, 0),
(5023, 3, '数字格式', 'NUMBER', 'system_sequence_detail_rule_type', 0, 'info', '', '数字格式规则', 'admin', SYSDATE, 'admin', SYSDATE, 0),
- (5024, 4, '自定义格式', 'CUSTOM', 'system_sequence_detail_rule_type', 0, 'warning', '', '自定义格式规则', 'admin', SYSDATE, 'admin', SYSDATE, 0); (5024, 4, '自定义格式', 'CUSTOM', 'system_sequence_detail_rule_type', 0, 'warning', '', '自定义格式规则', 'admin', NOW(), 'admin', NOW(), b'0');
+ (5024, 4, '自定义格式', 'CUSTOM', 'system_sequence_detail_rule_type', 0, 'warning', '', '自定义格式规则', 'admin', SYSDATE, 'admin', SYSDATE, 0);
diff --git a/sql/mysql/doc_management.sql b/sql/mysql/doc_management.sql
new file mode 100644
index 00000000..c3be7477
--- /dev/null
+++ b/sql/mysql/doc_management.sql
@@ -0,0 +1,75 @@
+-- 在线文档管理功能相关表结构
+
+-- 在线文档表
+CREATE TABLE `infra_doc_file` (
+ `id` bigint NOT NULL AUTO_INCREMENT COMMENT '文档编号',
+ `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文档标题',
+ `file_id` bigint DEFAULT NULL COMMENT '文件编号',
+ `file_type` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件类型(docx/xlsx/pptx)',
+ `space_type` tinyint NOT NULL DEFAULT '1' COMMENT '空间类型(1-个人空间 2-团队空间)',
+ `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文档描述',
+ `latest_version_id` bigint DEFAULT NULL COMMENT '最新版本编号',
+ `owner_user_id` bigint NOT NULL COMMENT '所有者用户编号',
+ `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态(0-禁用 1-启用)',
+ `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
+ `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+ `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号',
+ PRIMARY KEY (`id`) USING BTREE,
+ KEY `idx_owner_user_id` (`owner_user_id`) USING BTREE,
+ KEY `idx_space_type` (`space_type`) USING BTREE,
+ KEY `idx_file_type` (`file_type`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='在线文档表';
+
+-- 文档版本表
+CREATE TABLE `infra_doc_file_version` (
+ `id` bigint NOT NULL AUTO_INCREMENT COMMENT '版本编号',
+ `doc_file_id` bigint NOT NULL COMMENT '文档编号',
+ `version_no` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '版本号',
+ `file_id` bigint NOT NULL COMMENT '文件编号',
+ `change_description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更说明',
+ `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
+ `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+ `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号',
+ PRIMARY KEY (`id`) USING BTREE,
+ KEY `idx_doc_file_id` (`doc_file_id`) USING BTREE,
+ KEY `idx_version_no` (`version_no`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文档版本表';
+
+-- 文档权限表
+CREATE TABLE `infra_doc_file_permission` (
+ `id` bigint NOT NULL AUTO_INCREMENT COMMENT '权限编号',
+ `doc_file_id` bigint NOT NULL COMMENT '文档编号',
+ `role_id` bigint NOT NULL COMMENT '角色编号',
+ `permission_type` tinyint NOT NULL DEFAULT '1' COMMENT '权限类型(1-只读 2-编辑 3-管理)',
+ `expire_time` datetime DEFAULT NULL COMMENT '过期时间',
+ `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
+ `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+ `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号',
+ PRIMARY KEY (`id`) USING BTREE,
+ UNIQUE KEY `uk_doc_role` (`doc_file_id`,`role_id`,`deleted`) USING BTREE,
+ KEY `idx_role_id` (`role_id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文档权限表';
+
+-- 文档编辑历史表
+CREATE TABLE `infra_doc_edit_history` (
+ `id` bigint NOT NULL AUTO_INCREMENT COMMENT '历史编号',
+ `doc_file_id` bigint NOT NULL COMMENT '文档编号',
+ `user_id` bigint NOT NULL COMMENT '编辑用户编号',
+ `user_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '编辑用户名称',
+ `edit_type` tinyint NOT NULL DEFAULT '1' COMMENT '编辑类型(1-创建 2-编辑 3-删除 4-重命名)',
+ `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作描述',
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号',
+ PRIMARY KEY (`id`) USING BTREE,
+ KEY `idx_doc_file_id` (`doc_file_id`) USING BTREE,
+ KEY `idx_user_id` (`user_id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文档编辑历史表';
diff --git a/sql/mysql/doc_management_menu.sql b/sql/mysql/doc_management_menu.sql
new file mode 100644
index 00000000..808a54ff
--- /dev/null
+++ b/sql/mysql/doc_management_menu.sql
@@ -0,0 +1,24 @@
+-- 在线文档管理功能菜单数据
+
+-- 菜单 SQL
+-- 约定:6000 顶级目录;6001 子菜单;60010x 按钮;6002 编辑器隐藏子页面
+DELETE FROM system_menu WHERE id IN (6000,6001,6002,600101,600102,600103,600104,600105,600106,600107,600108,600109,600110);
+
+INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted)
+VALUES (6000, '在线文档', '', 1, 15, 2, 'doc', 'fa:file-text-o', '', 0, true, true, true, 'admin', NOW(), '', NOW(), false);
+
+INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted)
+VALUES (6001, '文档管理', 'infra:doc:query', 2, 1, 6000, 'doc-file', 'fa:file-text', 'infra/doc/index', 0, true, true, true, 'admin', NOW(), '', NOW(), false);
+
+-- 文档管理的操作权限
+INSERT INTO system_menu (id,name, permission, type, sort, parent_id, path, icon, component, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) VALUES
+(600101,'文档查询','infra:doc:query',3,1,6001,'','','',0,true,true,true,'admin',NOW(),'admin',NOW(),false),
+(600102,'文档创建','infra:doc:create',3,2,6001,'','','',0,true,true,true,'admin',NOW(),'admin',NOW(),false),
+(600103,'文档更新','infra:doc:update',3,3,6001,'','','',0,true,true,true,'admin',NOW(),'admin',NOW(),false),
+(600104,'文档删除','infra:doc:delete',3,4,6001,'','','',0,true,true,true,'admin',NOW(),'admin',NOW(),false),
+(600105,'文档导出','infra:doc:export',3,5,6001,'','','',0,true,true,true,'admin',NOW(),'admin',NOW(),false),
+(600106,'文档上传','infra:doc:upload',3,6,6001,'','','',0,true,true,true,'admin',NOW(),'admin',NOW(),false),
+(600107,'文档编辑','infra:doc:edit',3,7,6001,'','','',0,true,true,true,'admin',NOW(),'admin',NOW(),false),
+(600108,'文档预览','infra:doc:preview',3,8,6001,'','','',0,true,true,true,'admin',NOW(),'admin',NOW(),false),
+(600109,'文档下载','infra:doc:download',3,9,6001,'','','',0,true,true,true,'admin',NOW(),'admin',NOW(),false),
+(600110,'权限管理','infra:doc:permission',3,10,6001,'','','',0,true,true,true,'admin',NOW(),'admin',NOW(),false);
diff --git a/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java b/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java
index d920c75f..3d306bac 100644
--- a/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java
+++ b/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java
@@ -86,4 +86,5 @@ public interface ErrorCodeConstants {
ErrorCode STANDARD_NAME_NOT_EXISTS = new ErrorCode(1_002_030_000, "数据命名与简写标准不存在");
ErrorCode STD_ABBR_NOT_EXISTS = new ErrorCode(1_002_030_001, "字段名 {} 不存在命名规范定义,请核对字段定义");
+ ErrorCode DOC_NOT_EXISTS = new ErrorCode(1_002_031_000, "文档不存在");
}
diff --git a/yudao-module-infra/yudao-module-infra-server/pom.xml b/yudao-module-infra/yudao-module-infra-server/pom.xml
index face0f7b..1cce5b0c 100644
--- a/yudao-module-infra/yudao-module-infra-server/pom.xml
+++ b/yudao-module-infra/yudao-module-infra-server/pom.xml
@@ -32,6 +32,12 @@
${revision}
+
+ cn.iocoder.cloud
+ yudao-module-system-api
+ ${revision}
+
+
cn.iocoder.cloud
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/config/WebSocketConfig.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/config/WebSocketConfig.java
new file mode 100644
index 00000000..b3184c9d
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/config/WebSocketConfig.java
@@ -0,0 +1,30 @@
+package cn.iocoder.yudao.module.infra.config;
+
+import cn.iocoder.yudao.module.infra.websocket.DocWebSocketHandler;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.socket.config.annotation.EnableWebSocket;
+import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
+import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
+
+import jakarta.annotation.Resource;
+
+/**
+ * WebSocket 配置
+ *
+ * @author 芋道源码
+ */
+@Configuration
+@EnableWebSocket
+public class WebSocketConfig implements WebSocketConfigurer {
+
+ @Resource
+ private DocWebSocketHandler docWebSocketHandler;
+
+ @Override
+ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
+ // 注册文档协作WebSocket处理器
+ registry.addHandler(docWebSocketHandler, "/doc-collaboration")
+ .setAllowedOrigins("*") // 允许跨域,生产环境应该限制域名
+ .withSockJS(); // 启用SockJS支持
+ }
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/DocFileController.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/DocFileController.java
new file mode 100644
index 00000000..5f9fa990
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/DocFileController.java
@@ -0,0 +1,288 @@
+package cn.iocoder.yudao.module.infra.controller.admin.doc;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
+import cn.iocoder.yudao.module.infra.controller.admin.doc.vo.*;
+import cn.iocoder.yudao.module.infra.convert.doc.DocFileConvert;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFilePermissionDO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFileVersionDO;
+import cn.iocoder.yudao.module.infra.service.doc.DocFileService;
+import cn.iocoder.yudao.module.infra.service.file.FileService;
+import cn.hutool.json.JSONUtil;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.annotation.security.PermitAll;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+import java.util.Map;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+@Tag(name = "管理后台 - 在线文档")
+@RestController
+@RequestMapping("/infra/doc-file")
+@Validated
+@Slf4j
+public class DocFileController {
+
+ @Resource
+ private DocFileService docFileService;
+
+ @Resource
+ private FileService fileService;
+
+ @PostMapping("/create")
+ @Operation(summary = "创建在线文档")
+ @PreAuthorize("@ss.hasPermission('infra:doc:create')")
+ public CommonResult createDocFile(@Valid @RequestBody DocFileCreateReqVO createReqVO) {
+ return success(docFileService.createDocFile(createReqVO));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "修改在线文档")
+ @PreAuthorize("@ss.hasPermission('infra:doc:update')")
+ public CommonResult updateDocFile(@Valid @RequestBody DocFileUpdateReqVO updateReqVO) {
+ docFileService.updateDocFile(updateReqVO);
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "删除在线文档")
+ @PreAuthorize("@ss.hasPermission('infra:doc:delete')")
+ public CommonResult deleteDocFile(@RequestParam("id") Long id) {
+ docFileService.deleteDocFile(id);
+ return success(true);
+ }
+
+ @GetMapping("/get")
+ @Operation(summary = "获得在线文档")
+ @PreAuthorize("@ss.hasPermission('infra:doc:query')")
+ public CommonResult getDocFile(@RequestParam("id") Long id) {
+ DocFileRespVO doc = docFileService.getDocFileWithFileInfo(id);
+ return success(doc);
+ }
+
+ @GetMapping("/page")
+ @Operation(summary = "获得在线文档分页")
+ @PreAuthorize("@ss.hasPermission('infra:doc:query')")
+ public CommonResult> getDocFilePage(@Valid DocFilePageReqVO pageReqVO) {
+ PageResult page = docFileService.getDocFilePageWithFileInfo(pageReqVO);
+ return success(page);
+ }
+
+ @PostMapping("/upload")
+ @Operation(summary = "上传文档文件")
+ @PreAuthorize("@ss.hasPermission('infra:doc:upload')")
+ public CommonResult upload(@RequestPart("file") MultipartFile file,
+ @RequestParam("title") String title,
+ @RequestParam("spaceType") Integer spaceType,
+ @RequestParam(value = "description", required = false) String description) {
+ return success(docFileService.uploadDocFile(file, title, spaceType, description));
+ }
+
+ @GetMapping("/editor-config")
+ @Operation(summary = "获取 OnlyOffice 编辑器配置")
+ @PreAuthorize("@ss.hasPermission('infra:doc:edit')")
+ public CommonResult getEditorConfig(@RequestParam("id") Long id) {
+ return success(docFileService.getEditorConfig(id));
+ }
+
+ @GetMapping("/file-content")
+ @Operation(summary = "获取文档文件内容")
+ @PermitAll
+ public void getFileContent(@RequestParam("fileId") Long fileId,
+ @RequestParam("token") String token,
+ HttpServletResponse response) {
+ docFileService.getDocFileContent(fileId, token, response);
+ }
+
+ @PostMapping("/callback")
+ @Operation(summary = "OnlyOffice 回调接口")
+ @PermitAll
+ @TenantIgnore
+ public Map callback(@RequestParam("docFileId") Long docFileId,
+ @RequestParam(value = "userId", required = false) Long triggerUserId,
+ @RequestBody Map body,
+ HttpServletRequest request) {
+ try {
+ log.info("=== OnlyOffice 回调接口被调用 ===");
+ log.info("请求URL: {}", request.getRequestURL());
+ log.info("请求URI: {}", request.getRequestURI());
+ log.info("请求方法: {}", request.getMethod());
+ log.info("Content-Type: {}", request.getContentType());
+ log.info("User-Agent: {}", request.getHeader("User-Agent"));
+ log.info("客户端IP: {}", getClientIpAddress(request));
+ log.info("OnlyOffice 回调请求: docFileId={}, triggerUserId={}, body={}", docFileId, triggerUserId, body);
+
+ // 如果传入了triggerUserId,设置当前请求的登录用户为该用户
+ if (triggerUserId != null) {
+ LoginUser mockUser = new LoginUser();
+ mockUser.setId(triggerUserId);
+ mockUser.setUserType(UserTypeEnum.ADMIN.getValue());
+ mockUser.setTenantId(1L); // 默认租户
+ SecurityFrameworkUtils.setLoginUser(mockUser, request);
+ log.info("已设置登录用户上下文: userId={}", triggerUserId);
+ }
+
+ Integer status = (Integer) body.get("status");
+ String downloadUrl = (String) body.get("url");
+
+ // 扩展保存策略:更多状态下进行保存
+ boolean shouldSave = false;
+ String saveReason = "";
+
+ if (status != null) {
+ switch (status) {
+ case 1:
+ // 文档正在编辑中 - 记录活动但不保存
+ log.info("文档编辑中: docFileId={}, users={}", docFileId, body.get("users"));
+ break;
+ case 2:
+ // 文档已准备保存(用户主动保存或停止编辑)- 立即保存
+ shouldSave = true;
+ saveReason = "用户主动保存";
+ break;
+ case 3:
+ // 保存时出错 - 记录错误但不重试保存,避免循环
+ log.warn("OnlyOffice保存出错: docFileId={}, url={}", docFileId, downloadUrl);
+ break;
+ case 4:
+ // 文档关闭且无错误 - 只在有实际内容时保存
+ if (downloadUrl != null && !downloadUrl.trim().isEmpty()) {
+ shouldSave = true;
+ saveReason = "文档关闭保存";
+ }
+ break;
+ case 6:
+ // 强制保存(通常是协作冲突解决) - 立即保存
+ shouldSave = true;
+ saveReason = "协作保存";
+ break;
+ case 7:
+ // 保存时出错但继续编辑 - 记录错误但不重试
+ log.warn("OnlyOffice编辑中保存出错: docFileId={}, url={}", docFileId, downloadUrl);
+ break;
+ default:
+ log.info("OnlyOffice未处理的回调状态: docFileId={}, status={}", docFileId, status);
+ }
+ }
+
+ if (shouldSave) {
+ if (downloadUrl == null || downloadUrl.trim().isEmpty()) {
+ log.error("OnlyOffice 回调 URL 为空: docFileId={}, status={}, reason={}", docFileId, status, saveReason);
+ return Map.of("error", 1, "message", "下载URL为空");
+ }
+
+ log.info("开始保存文档: docFileId={}, triggerUserId={}, status={}, reason={}, url={}",
+ docFileId, triggerUserId, status, saveReason, downloadUrl);
+ docFileService.saveDocumentContent(docFileId, downloadUrl, saveReason, triggerUserId);
+ log.info("文档保存成功: docFileId={}, triggerUserId={}, reason={}", docFileId, triggerUserId, saveReason);
+ }
+
+ // 按 OnlyOffice 协议返回 {error:0}
+ return Map.of("error", 0);
+ } catch (Exception e) {
+ log.error("OnlyOffice 回调处理失败: docFileId={}, body={}", docFileId, body, e);
+ return Map.of("error", 1, "message", e.getMessage());
+ }
+ }
+
+ // ------------------- 权限 -------------------
+
+ @PostMapping("/permission/set")
+ @Operation(summary = "设置文档权限")
+ @PreAuthorize("@ss.hasPermission('infra:doc:permission')")
+ public CommonResult setPermission(@Valid @RequestBody DocFilePermissionReqVO permissionReqVO) {
+ docFileService.setDocFilePermission(permissionReqVO);
+ return success(true);
+ }
+
+ @GetMapping("/permission/list")
+ @Operation(summary = "获得文档权限列表")
+ @PreAuthorize("@ss.hasPermission('infra:doc:permission')")
+ public CommonResult> getPermissionList(@RequestParam("docFileId") Long docFileId) {
+ List list = docFileService.getDocFilePermissions(docFileId);
+ return success(DocFileConvert.INSTANCE.convertPermissionList(list));
+ }
+
+ @DeleteMapping("/permission/delete")
+ @Operation(summary = "删除文档权限")
+ @PreAuthorize("@ss.hasPermission('infra:doc:permission')")
+ public CommonResult deletePermission(@RequestParam("docFileId") Long docFileId,
+ @RequestParam("roleId") Long roleId) {
+ docFileService.deleteDocFilePermission(docFileId, roleId);
+ return success(true);
+ }
+
+ // ========== 版本管理 ==========
+
+ @GetMapping("/version/list")
+ @Operation(summary = "获取文档版本列表")
+ @PreAuthorize("@ss.hasPermission('infra:doc:query')")
+ public CommonResult> getVersionList(@RequestParam("docFileId") Long docFileId) {
+ List list = docFileService.getDocFileVersions(docFileId);
+ List result = DocFileConvert.INSTANCE.convertVersionList(list);
+
+ // 填充文件信息
+ for (DocFileVersionRespVO vo : result) {
+ if (vo.getFileId() != null) {
+ try {
+ var fileInfo = fileService.getActiveFileById(vo.getFileId());
+ if (fileInfo != null) {
+ vo.setFileName(fileInfo.getName());
+ vo.setFileSize(fileInfo.getSize() != null ? fileInfo.getSize().longValue() : 0L);
+ }
+ } catch (Exception e) {
+ log.warn("获取版本文件信息失败: versionId={}, fileId={}", vo.getId(), vo.getFileId(), e);
+ }
+ }
+ }
+
+ return success(result);
+ }
+
+ @PostMapping("/version/restore")
+ @Operation(summary = "恢复到指定版本")
+ @PreAuthorize("@ss.hasPermission('infra:doc:update')")
+ public CommonResult restoreToVersion(@RequestParam("docFileId") Long docFileId,
+ @RequestParam("versionId") Long versionId) {
+ docFileService.restoreDocFileToVersion(docFileId, versionId);
+ return success(true);
+ }
+
+ /**
+ * 获取客户端真实IP地址
+ */
+ private String getClientIpAddress(HttpServletRequest request) {
+ String ip = request.getHeader("X-Forwarded-For");
+ if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getHeader("Proxy-Client-IP");
+ }
+ if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getHeader("WL-Proxy-Client-IP");
+ }
+ if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getHeader("HTTP_CLIENT_IP");
+ }
+ if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getHeader("HTTP_X_FORWARDED_FOR");
+ }
+ if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getRemoteAddr();
+ }
+ return ip;
+ }
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocEditorConfigRespVO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocEditorConfigRespVO.java
new file mode 100644
index 00000000..80cf67ec
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocEditorConfigRespVO.java
@@ -0,0 +1,99 @@
+package cn.iocoder.yudao.module.infra.controller.admin.doc.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Schema(description = "管理后台 - OnlyOffice 编辑器配置 Response VO")
+@Data
+public class DocEditorConfigRespVO {
+
+ @Schema(description = "文档类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "text")
+ private String documentType;
+
+ @Schema(description = "文档配置", requiredMode = Schema.RequiredMode.REQUIRED)
+ private DocumentConfig document;
+
+ @Schema(description = "编辑器配置", requiredMode = Schema.RequiredMode.REQUIRED)
+ private EditorConfig editorConfig;
+
+ @Schema(description = "文档高度", requiredMode = Schema.RequiredMode.REQUIRED, example = "100%")
+ private String height;
+
+ @Schema(description = "令牌", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String token;
+
+ @Schema(description = "OnlyOffice服务器地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "http://localhost:8085")
+ private String documentServerUrl;
+
+ @Schema(description = "文档配置")
+ @Data
+ public static class DocumentConfig {
+ @Schema(description = "文件类型", example = "docx")
+ private String fileType;
+
+ @Schema(description = "文档key", example = "doc123")
+ private String key;
+
+ @Schema(description = "文档标题", example = "技术文档")
+ private String title;
+
+ @Schema(description = "文档地址")
+ private String url;
+
+ @Schema(description = "权限配置")
+ private Permissions permissions;
+ }
+
+ @Schema(description = "权限配置")
+ @Data
+ public static class Permissions {
+ @Schema(description = "是否可编辑", example = "true")
+ private Boolean edit;
+
+ @Schema(description = "是否可下载", example = "true")
+ private Boolean download;
+
+ @Schema(description = "是否可打印", example = "true")
+ private Boolean print;
+ }
+
+ @Schema(description = "编辑器配置")
+ @Data
+ public static class EditorConfig {
+ @Schema(description = "回调地址")
+ private String callbackUrl;
+
+ @Schema(description = "语言", example = "zh-CN")
+ private String lang;
+
+ @Schema(description = "模式", example = "edit")
+ private String mode;
+
+ @Schema(description = "用户配置")
+ private User user;
+
+ @Schema(description = "协作配置")
+ private CoEditing coEditing;
+ }
+
+ @Schema(description = "用户配置")
+ @Data
+ public static class User {
+ @Schema(description = "用户ID", example = "1")
+ private String id;
+
+ @Schema(description = "用户名", example = "admin")
+ private String name;
+ }
+
+ @Schema(description = "协作配置")
+ @Data
+ public static class CoEditing {
+ @Schema(description = "协作模式", example = "fast")
+ private String mode;
+
+ @Schema(description = "是否允许更改", example = "true")
+ private Boolean change;
+ }
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileCreateReqVO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileCreateReqVO.java
new file mode 100644
index 00000000..739b8018
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileCreateReqVO.java
@@ -0,0 +1,31 @@
+package cn.iocoder.yudao.module.infra.controller.admin.doc.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+
+@Schema(description = "管理后台 - 在线文档创建 Request VO")
+@Data
+public class DocFileCreateReqVO {
+
+ @Schema(description = "文档标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "技术文档")
+ @NotEmpty(message = "文档标题不能为空")
+ private String title;
+
+ @Schema(description = "文件类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "docx")
+ @NotEmpty(message = "文件类型不能为空")
+ private String fileType;
+
+ @Schema(description = "空间类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "空间类型不能为空")
+ private Integer spaceType;
+
+ @Schema(description = "文档描述", example = "这是一个技术文档")
+ private String description;
+
+ @Schema(description = "文件编号", example = "1")
+ private Long fileId;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFilePageReqVO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFilePageReqVO.java
new file mode 100644
index 00000000..5f172729
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFilePageReqVO.java
@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.module.infra.controller.admin.doc.vo;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - 在线文档分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class DocFilePageReqVO extends PageParam {
+
+ @Schema(description = "文档标题", example = "技术文档")
+ private String title;
+
+ @Schema(description = "文件类型", example = "docx")
+ private String fileType;
+
+ @Schema(description = "空间类型", example = "1")
+ private Integer spaceType;
+
+ @Schema(description = "状态", example = "1")
+ private Integer status;
+
+ @Schema(description = "创建时间")
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+ private LocalDateTime[] createTime;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFilePermissionReqVO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFilePermissionReqVO.java
new file mode 100644
index 00000000..a36bb489
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFilePermissionReqVO.java
@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.module.infra.controller.admin.doc.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - 文档权限 Request VO")
+@Data
+public class DocFilePermissionReqVO {
+
+ @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "文档编号不能为空")
+ private Long docFileId;
+
+ @Schema(description = "角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "角色编号不能为空")
+ private Long roleId;
+
+ @Schema(description = "权限类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "权限类型不能为空")
+ private Integer permissionType;
+
+ @Schema(description = "过期时间", example = "2024-12-31 23:59:59")
+ private LocalDateTime expireTime;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFilePermissionRespVO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFilePermissionRespVO.java
new file mode 100644
index 00000000..ec28f497
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFilePermissionRespVO.java
@@ -0,0 +1,33 @@
+package cn.iocoder.yudao.module.infra.controller.admin.doc.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - 文档权限 Response VO")
+@Data
+public class DocFilePermissionRespVO {
+
+ @Schema(description = "权限编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long docFileId;
+
+ @Schema(description = "角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long roleId;
+
+ @Schema(description = "角色名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "管理员")
+ private String roleName;
+
+ @Schema(description = "权限类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer permissionType;
+
+ @Schema(description = "过期时间", example = "2024-12-31 23:59:59")
+ private LocalDateTime expireTime;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
+ private LocalDateTime createTime;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileRespVO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileRespVO.java
new file mode 100644
index 00000000..cb6adb07
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileRespVO.java
@@ -0,0 +1,52 @@
+package cn.iocoder.yudao.module.infra.controller.admin.doc.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - 在线文档 Response VO")
+@Data
+public class DocFileRespVO {
+
+ @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "文档标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "技术文档")
+ private String title;
+
+ @Schema(description = "文件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
+ private Long fileId;
+
+ @Schema(description = "文件类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "docx")
+ private String fileType;
+
+ @Schema(description = "空间类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer spaceType;
+
+ @Schema(description = "文档描述", example = "这是一个技术文档")
+ private String description;
+
+ @Schema(description = "最新版本编号", example = "3072")
+ private Long latestVersionId;
+
+ @Schema(description = "所有者用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long ownerUserId;
+
+ @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer status;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
+ private LocalDateTime createTime;
+
+ // 以下字段从 fileService 动态获取
+ @Schema(description = "文件名称", example = "document.docx")
+ private String fileName;
+
+ @Schema(description = "文件大小", example = "1024")
+ private Long fileSize;
+
+ @Schema(description = "文件访问地址", example = "http://127.0.0.1:48080/admin-api/infra/file/4/get/37e56010ecbee472cdd821ac073b8")
+ private String fileUrl;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileUpdateReqVO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileUpdateReqVO.java
new file mode 100644
index 00000000..5ea74b26
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileUpdateReqVO.java
@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.module.infra.controller.admin.doc.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.constraints.NotNull;
+
+@Schema(description = "管理后台 - 在线文档更新 Request VO")
+@Data
+public class DocFileUpdateReqVO {
+
+ @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "文档编号不能为空")
+ private Long id;
+
+ @Schema(description = "文档标题", example = "技术文档")
+ private String title;
+
+ @Schema(description = "文档描述", example = "这是一个技术文档")
+ private String description;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileVersionRespVO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileVersionRespVO.java
new file mode 100644
index 00000000..f35067e2
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/doc/vo/DocFileVersionRespVO.java
@@ -0,0 +1,40 @@
+package cn.iocoder.yudao.module.infra.controller.admin.doc.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - 文档版本 Response VO")
+@Data
+public class DocFileVersionRespVO {
+
+ @Schema(description = "版本编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long id;
+
+ @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long docFileId;
+
+ @Schema(description = "版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "v1")
+ private String versionNo;
+
+ @Schema(description = "文件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long fileId;
+
+ @Schema(description = "变更说明", example = "修改文档内容")
+ private String changeDescription;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
+ private LocalDateTime createTime;
+
+ @Schema(description = "创建者", example = "1")
+ private String creator;
+
+ // 额外字段,从文件服务获取
+ @Schema(description = "文件名称", example = "document.docx")
+ private String fileName;
+
+ @Schema(description = "文件大小", example = "1024")
+ private Long fileSize;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/convert/doc/DocFileConvert.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/convert/doc/DocFileConvert.java
new file mode 100644
index 00000000..714e242e
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/convert/doc/DocFileConvert.java
@@ -0,0 +1,41 @@
+package cn.iocoder.yudao.module.infra.convert.doc;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.infra.controller.admin.doc.vo.*;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFileDO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFilePermissionDO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFileVersionDO;
+import org.mapstruct.Mapper;
+import org.mapstruct.factory.Mappers;
+
+import java.util.List;
+
+/**
+ * 在线文档 Convert
+ *
+ * @author 芋道源码
+ */
+@Mapper
+public interface DocFileConvert {
+
+ DocFileConvert INSTANCE = Mappers.getMapper(DocFileConvert.class);
+
+ DocFileDO convert(DocFileCreateReqVO bean);
+
+ DocFileRespVO convert(DocFileDO bean);
+
+ List convertList(List list);
+
+ PageResult convertPage(PageResult page);
+
+ DocFilePermissionDO convert(DocFilePermissionReqVO bean);
+
+ DocFilePermissionRespVO convert(DocFilePermissionDO bean);
+
+ List convertPermissionList(List list);
+
+ DocFileVersionRespVO convert(DocFileVersionDO bean);
+
+ List convertVersionList(List list);
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocEditHistoryDO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocEditHistoryDO.java
new file mode 100644
index 00000000..1c9ecf3d
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocEditHistoryDO.java
@@ -0,0 +1,61 @@
+package cn.iocoder.yudao.module.infra.dal.dataobject.doc;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+/**
+ * 文档编辑历史 DO
+ *
+ * @author 芋道源码
+ */
+@TableName("infra_doc_edit_history")
+@KeySequence("infra_doc_edit_history_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@ToString
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DocEditHistoryDO {
+
+ /**
+ * 历史编号
+ */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+ /**
+ * 文档编号
+ */
+ private Long docFileId;
+ /**
+ * 编辑用户编号
+ */
+ private Long userId;
+ /**
+ * 编辑用户名称
+ */
+ private String userName;
+ /**
+ * 编辑类型
+ *
+ * 枚举 {@link cn.iocoder.yudao.module.infra.enums.doc.DocEditTypeEnum}
+ */
+ private Integer editType;
+ /**
+ * 操作描述
+ */
+ private String description;
+ /**
+ * 创建时间
+ */
+ private LocalDateTime createTime;
+ /**
+ * 租户编号
+ */
+ private Long tenantId;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocFileDO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocFileDO.java
new file mode 100644
index 00000000..cec6edf0
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocFileDO.java
@@ -0,0 +1,69 @@
+package cn.iocoder.yudao.module.infra.dal.dataobject.doc;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+/**
+ * 在线文档 DO
+ *
+ * @author 芋道源码
+ */
+@TableName("infra_doc_file")
+@KeySequence("infra_doc_file_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DocFileDO extends BaseDO {
+
+ /**
+ * 文档编号
+ */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+ /**
+ * 文档标题
+ */
+ private String title;
+ /**
+ * 文件编号
+ */
+ private Long fileId;
+ /**
+ * 文件类型
+ *
+ * 枚举 {@link cn.iocoder.yudao.module.infra.enums.doc.DocFileTypeEnum}
+ */
+ private String fileType;
+ /**
+ * 空间类型
+ *
+ * 枚举 {@link cn.iocoder.yudao.module.infra.enums.doc.DocSpaceTypeEnum}
+ */
+ private Integer spaceType;
+ /**
+ * 文档描述
+ */
+ private String description;
+ /**
+ * 最新版本编号
+ */
+ private Long latestVersionId;
+ /**
+ * 所有者用户编号
+ */
+ private Long ownerUserId;
+ /**
+ * 状态
+ *
+ * 枚举 {@link cn.iocoder.yudao.framework.common.enums.CommonStatusEnum}
+ */
+ private Integer status;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocFilePermissionDO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocFilePermissionDO.java
new file mode 100644
index 00000000..02cf6ab3
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocFilePermissionDO.java
@@ -0,0 +1,51 @@
+package cn.iocoder.yudao.module.infra.dal.dataobject.doc;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+/**
+ * 文档权限 DO
+ *
+ * @author 芋道源码
+ */
+@TableName("infra_doc_file_permission")
+@KeySequence("infra_doc_file_permission_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DocFilePermissionDO extends BaseDO {
+
+ /**
+ * 权限编号
+ */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+ /**
+ * 文档编号
+ */
+ private Long docFileId;
+ /**
+ * 角色编号
+ */
+ private Long roleId;
+ /**
+ * 权限类型
+ *
+ * 枚举 {@link cn.iocoder.yudao.module.infra.enums.doc.DocPermissionTypeEnum}
+ */
+ private Integer permissionType;
+ /**
+ * 过期时间
+ */
+ private LocalDateTime expireTime;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocFileVersionDO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocFileVersionDO.java
new file mode 100644
index 00000000..c8249c09
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/doc/DocFileVersionDO.java
@@ -0,0 +1,47 @@
+package cn.iocoder.yudao.module.infra.dal.dataobject.doc;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+/**
+ * 文档版本 DO
+ *
+ * @author 芋道源码
+ */
+@TableName("infra_doc_file_version")
+@KeySequence("infra_doc_file_version_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DocFileVersionDO extends BaseDO {
+
+ /**
+ * 版本编号
+ */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+ /**
+ * 文档编号
+ */
+ private Long docFileId;
+ /**
+ * 版本号
+ */
+ private String versionNo;
+ /**
+ * 文件编号
+ */
+ private Long fileId;
+ /**
+ * 变更说明
+ */
+ private String changeDescription;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocEditHistoryMapper.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocEditHistoryMapper.java
new file mode 100644
index 00000000..97410608
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocEditHistoryMapper.java
@@ -0,0 +1,24 @@
+package cn.iocoder.yudao.module.infra.dal.mysql.doc;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocEditHistoryDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 文档编辑历史 Mapper
+ *
+ * @author 芋道源码
+ */
+@Mapper
+public interface DocEditHistoryMapper extends BaseMapperX {
+
+ default List selectListByDocFileId(Long docFileId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(DocEditHistoryDO::getDocFileId, docFileId)
+ .orderByDesc(DocEditHistoryDO::getCreateTime));
+ }
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocFileMapper.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocFileMapper.java
new file mode 100644
index 00000000..7be76cc5
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocFileMapper.java
@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.module.infra.dal.mysql.doc;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.module.infra.controller.admin.doc.vo.DocFilePageReqVO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFileDO;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 在线文档 Mapper
+ *
+ * @author 芋道源码
+ */
+@Mapper
+public interface DocFileMapper extends BaseMapperX {
+
+ default PageResult selectPage(DocFilePageReqVO reqVO) {
+ return selectPage(reqVO, new LambdaQueryWrapperX()
+ .likeIfPresent(DocFileDO::getTitle, reqVO.getTitle())
+ .eqIfPresent(DocFileDO::getFileType, reqVO.getFileType())
+ .eqIfPresent(DocFileDO::getSpaceType, reqVO.getSpaceType())
+ .betweenIfPresent(DocFileDO::getCreateTime, reqVO.getCreateTime())
+ .eqIfPresent(DocFileDO::getStatus, reqVO.getStatus())
+ .orderByDesc(DocFileDO::getId));
+ }
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocFilePermissionMapper.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocFilePermissionMapper.java
new file mode 100644
index 00000000..8523239a
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocFilePermissionMapper.java
@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.module.infra.dal.mysql.doc;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFilePermissionDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 文档权限 Mapper
+ *
+ * @author 芋道源码
+ */
+@Mapper
+public interface DocFilePermissionMapper extends BaseMapperX {
+
+ default List selectListByDocFileId(Long docFileId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(DocFilePermissionDO::getDocFileId, docFileId));
+ }
+
+ default DocFilePermissionDO selectByDocFileIdAndRoleId(Long docFileId, Long roleId) {
+ return selectOne(new LambdaQueryWrapperX()
+ .eq(DocFilePermissionDO::getDocFileId, docFileId)
+ .eq(DocFilePermissionDO::getRoleId, roleId));
+ }
+
+ default List selectListByRoleId(Long roleId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(DocFilePermissionDO::getRoleId, roleId));
+ }
+
+ default List selectListByRoleIds(List roleIds) {
+ return selectList(new LambdaQueryWrapperX()
+ .in(DocFilePermissionDO::getRoleId, roleIds));
+ }
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocFileVersionMapper.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocFileVersionMapper.java
new file mode 100644
index 00000000..682f9259
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/doc/DocFileVersionMapper.java
@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.module.infra.dal.mysql.doc;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFileVersionDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 文档版本 Mapper
+ *
+ * @author 芋道源码
+ */
+@Mapper
+public interface DocFileVersionMapper extends BaseMapperX {
+
+ default List selectListByDocFileId(Long docFileId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(DocFileVersionDO::getDocFileId, docFileId)
+ .orderByDesc(DocFileVersionDO::getId));
+ }
+
+ default DocFileVersionDO selectLatestByDocFileId(Long docFileId) {
+ return selectOne(new LambdaQueryWrapperX()
+ .eq(DocFileVersionDO::getDocFileId, docFileId)
+ .orderByDesc(DocFileVersionDO::getId)
+ .last("LIMIT 1"));
+ }
+
+ default Long selectCountByDocFileId(Long docFileId) {
+ return selectCount(new LambdaQueryWrapperX()
+ .eq(DocFileVersionDO::getDocFileId, docFileId));
+ }
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocEditTypeEnum.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocEditTypeEnum.java
new file mode 100644
index 00000000..499d5397
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocEditTypeEnum.java
@@ -0,0 +1,29 @@
+package cn.iocoder.yudao.module.infra.enums.doc;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 文档编辑类型枚举
+ *
+ * @author 芋道源码
+ */
+@AllArgsConstructor
+@Getter
+public enum DocEditTypeEnum {
+
+ CREATE(1, "创建"),
+ EDIT(2, "编辑"),
+ DELETE(3, "删除"),
+ RENAME(4, "重命名");
+
+ /**
+ * 类型
+ */
+ private final Integer type;
+ /**
+ * 描述
+ */
+ private final String description;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocFileTypeEnum.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocFileTypeEnum.java
new file mode 100644
index 00000000..1ad59f26
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocFileTypeEnum.java
@@ -0,0 +1,29 @@
+package cn.iocoder.yudao.module.infra.enums.doc;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 文档文件类型枚举
+ *
+ * @author 芋道源码
+ */
+@AllArgsConstructor
+@Getter
+public enum DocFileTypeEnum {
+
+ DOCX("docx", "Word文档"),
+ XLSX("xlsx", "Excel表格"),
+ PPTX("pptx", "PowerPoint演示文稿"),
+ PDF("pdf", "PDF文档");
+
+ /**
+ * 类型
+ */
+ private final String type;
+ /**
+ * 描述
+ */
+ private final String description;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocPermissionTypeEnum.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocPermissionTypeEnum.java
new file mode 100644
index 00000000..b73e2553
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocPermissionTypeEnum.java
@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.module.infra.enums.doc;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 文档权限类型枚举
+ *
+ * @author 芋道源码
+ */
+@AllArgsConstructor
+@Getter
+public enum DocPermissionTypeEnum {
+
+ READ(1, "只读"),
+ EDIT(2, "编辑"),
+ MANAGE(3, "管理");
+
+ /**
+ * 类型
+ */
+ private final Integer type;
+ /**
+ * 描述
+ */
+ private final String description;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocSpaceTypeEnum.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocSpaceTypeEnum.java
new file mode 100644
index 00000000..e23a6a21
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/enums/doc/DocSpaceTypeEnum.java
@@ -0,0 +1,27 @@
+package cn.iocoder.yudao.module.infra.enums.doc;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 文档空间类型枚举
+ *
+ * @author 芋道源码
+ */
+@AllArgsConstructor
+@Getter
+public enum DocSpaceTypeEnum {
+
+ PERSONAL(1, "个人空间"),
+ TEAM(2, "团队空间");
+
+ /**
+ * 类型
+ */
+ private final Integer type;
+ /**
+ * 描述
+ */
+ private final String description;
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/rpc/config/RpcConfiguration.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/rpc/config/RpcConfiguration.java
index eab0f724..e45d5af1 100644
--- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/rpc/config/RpcConfiguration.java
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/rpc/config/RpcConfiguration.java
@@ -1,9 +1,12 @@
package cn.iocoder.yudao.module.infra.framework.rpc.config;
+import cn.iocoder.yudao.module.system.api.permission.PermissionApi;
+import cn.iocoder.yudao.module.system.api.permission.RoleApi;
+import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;
@Configuration(value = "infraRpcConfiguration", proxyBeanMethods = false)
-@EnableFeignClients()
+@EnableFeignClients(clients = {PermissionApi.class, RoleApi.class, AdminUserApi.class})
public class RpcConfiguration {
}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/security/config/SecurityConfiguration.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/security/config/SecurityConfiguration.java
index f6533609..925127de 100644
--- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/security/config/SecurityConfiguration.java
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/security/config/SecurityConfiguration.java
@@ -38,6 +38,9 @@ public class SecurityConfiguration {
.requestMatchers(adminSeverContextPath + "/**").permitAll();
// 文件读取
registry.requestMatchers(buildAdminApi("/infra/file/*/get/**")).permitAll();
+ // 在线文档相关接口
+ registry.requestMatchers(buildAdminApi("/infra/doc-file/file-content")).permitAll();
+ registry.requestMatchers(buildAdminApi("/infra/doc-file/callback")).permitAll();
// TODO 芋艿:这个每个项目都需要重复配置,得捉摸有没通用的方案
// RPC 服务的安全配置
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/doc/DocFileService.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/doc/DocFileService.java
new file mode 100644
index 00000000..8e60eebc
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/doc/DocFileService.java
@@ -0,0 +1,172 @@
+package cn.iocoder.yudao.module.infra.service.doc;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.infra.controller.admin.doc.vo.*;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFileDO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFilePermissionDO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFileVersionDO;
+import org.springframework.web.multipart.MultipartFile;
+
+import jakarta.validation.Valid;
+import java.util.List;
+
+/**
+ * 在线文档 Service 接口
+ *
+ * @author 芋道源码
+ */
+public interface DocFileService {
+
+ /**
+ * 创建在线文档
+ *
+ * @param createReqVO 创建信息
+ * @return 编号
+ */
+ Long createDocFile(@Valid DocFileCreateReqVO createReqVO);
+
+ /**
+ * 更新在线文档
+ *
+ * @param updateReqVO 更新信息
+ */
+ void updateDocFile(@Valid DocFileUpdateReqVO updateReqVO);
+
+ /**
+ * 删除在线文档
+ *
+ * @param id 编号
+ */
+ void deleteDocFile(Long id);
+
+ /**
+ * 获得在线文档
+ *
+ * @param id 编号
+ * @return 在线文档
+ */
+ DocFileDO getDocFile(Long id);
+
+ /**
+ * 获得在线文档完整信息(包含文件信息)
+ *
+ * @param id 编号
+ * @return 在线文档完整信息
+ */
+ DocFileRespVO getDocFileWithFileInfo(Long id);
+
+ /**
+ * 获得在线文档分页
+ *
+ * @param pageReqVO 分页查询
+ * @return 在线文档分页
+ */
+ PageResult getDocFilePage(DocFilePageReqVO pageReqVO);
+
+ /**
+ * 获得在线文档分页(包含文件信息)
+ *
+ * @param pageReqVO 分页查询
+ * @return 在线文档分页
+ */
+ PageResult getDocFilePageWithFileInfo(DocFilePageReqVO pageReqVO);
+
+ /**
+ * 上传文档
+ *
+ * @param file 文件
+ * @param title 文档标题
+ * @param spaceType 空间类型
+ * @param description 描述
+ * @return 编号
+ */
+ Long uploadDocFile(MultipartFile file, String title, Integer spaceType, String description);
+
+ /**
+ * 获取编辑器配置
+ *
+ * @param id 文档编号
+ * @return 编辑器配置
+ */
+ DocEditorConfigRespVO getEditorConfig(Long id);
+
+ /**
+ * 获取文档文件内容
+ *
+ * @param fileId 文件编号
+ * @param token JWT令牌
+ * @param response HTTP响应对象
+ */
+ void getDocFileContent(Long fileId, String token, jakarta.servlet.http.HttpServletResponse response);
+
+ /**
+ * 保存文档内容(OnlyOffice回调)
+ *
+ * @param id 文档编号
+ * @param downloadUrl 下载地址
+ * @param changeDescription 变更说明
+ * @param triggerUserId 触发保存的用户ID(可选,为空时使用系统用户)
+ */
+ void saveDocumentContent(Long id, String downloadUrl, String changeDescription, Long triggerUserId);
+
+ /**
+ * 保存文档内容(OnlyOffice回调) - 兼容旧版本
+ *
+ * @param id 文档编号
+ * @param downloadUrl 下载地址
+ * @param changeDescription 变更说明
+ */
+ default void saveDocumentContent(Long id, String downloadUrl, String changeDescription) {
+ saveDocumentContent(id, downloadUrl, changeDescription, null);
+ }
+
+ /**
+ * 设置文档权限
+ *
+ * @param permissionReqVO 权限信息
+ */
+ void setDocFilePermission(@Valid DocFilePermissionReqVO permissionReqVO);
+
+ /**
+ * 获取文档权限列表
+ *
+ * @param docFileId 文档编号
+ * @return 权限列表
+ */
+ List getDocFilePermissions(Long docFileId);
+
+ /**
+ * 删除文档权限
+ *
+ * @param docFileId 文档编号
+ * @param roleId 角色编号
+ */
+ void deleteDocFilePermission(Long docFileId, Long roleId);
+
+ /**
+ * 检查用户是否有文档权限
+ *
+ * @param docFileId 文档编号
+ * @param userId 用户编号
+ * @param permissionType 权限类型
+ * @return 是否有权限
+ */
+ boolean hasPermission(Long docFileId, Long userId, Integer permissionType);
+
+ /**
+ * 获取文档版本历史
+ *
+ * @param docFileId 文档编号
+ * @return 版本列表
+ */
+ List getDocFileVersions(Long docFileId);
+
+ /**
+ * 恢复到指定版本
+ *
+ * @param docFileId 文档编号
+ * @param versionId 版本编号
+ */
+ void restoreDocFileToVersion(Long docFileId, Long versionId);
+
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/doc/impl/DocFileServiceImpl.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/doc/impl/DocFileServiceImpl.java
new file mode 100644
index 00000000..ec514671
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/doc/impl/DocFileServiceImpl.java
@@ -0,0 +1,849 @@
+package cn.iocoder.yudao.module.infra.service.doc.impl;
+
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.http.HttpUtil;
+import cn.hutool.jwt.JWT;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.yudao.module.infra.controller.admin.doc.vo.*;
+import cn.iocoder.yudao.module.infra.convert.doc.DocFileConvert;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocEditHistoryDO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFileDO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFilePermissionDO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.doc.DocFileVersionDO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
+import cn.iocoder.yudao.module.infra.dal.mysql.doc.DocEditHistoryMapper;
+import cn.iocoder.yudao.module.infra.dal.mysql.doc.DocFileMapper;
+import cn.iocoder.yudao.module.infra.dal.mysql.doc.DocFilePermissionMapper;
+import cn.iocoder.yudao.module.infra.dal.mysql.doc.DocFileVersionMapper;
+import cn.iocoder.yudao.module.infra.enums.doc.DocPermissionTypeEnum;
+import cn.iocoder.yudao.module.infra.service.doc.DocFileService;
+import cn.iocoder.yudao.module.infra.service.file.FileService;
+import cn.iocoder.yudao.module.infra.websocket.DocWebSocketHandler;
+import cn.iocoder.yudao.module.system.api.permission.PermissionApi;
+import cn.iocoder.yudao.module.system.api.permission.RoleApi;
+import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
+import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.DOC_NOT_EXISTS;
+
+/**
+ * 在线文档 Service 实现类
+ */
+@Service
+@Slf4j
+public class DocFileServiceImpl implements DocFileService {
+
+ // OnlyOffice支持的文件类型定义
+ private static final String TEXT_DOCUMENT_EXTENSIONS = "doc,docx,odt,rtf,txt,html,htm,mht,pdf,djvu,fb2,epub,xps";
+ private static final String SPREADSHEET_EXTENSIONS = "xls,xlsx,ods,csv";
+ private static final String PRESENTATION_EXTENSIONS = "ppt,pptx,odp";
+
+ // 文档类型常量
+ private static final String DOCUMENT_TYPE_TEXT = "text";
+ private static final String DOCUMENT_TYPE_SPREADSHEET = "spreadsheet";
+ private static final String DOCUMENT_TYPE_PRESENTATION = "presentation";
+
+ @Resource
+ private DocFileMapper docFileMapper;
+ @Resource
+ private DocFileVersionMapper docFileVersionMapper;
+ @Resource
+ private DocFilePermissionMapper docFilePermissionMapper;
+ @Resource
+ private DocEditHistoryMapper docEditHistoryMapper;
+ @Resource
+ private FileService fileService;
+ @Resource
+ private PermissionApi permissionApi;
+ @Resource
+ private RoleApi roleApi;
+ @Resource
+ private AdminUserApi adminUserApi;
+
+ @Value("${onlyoffice.base-url:http://localhost:8085}")
+ private String onlyOfficeBaseUrl;
+ @Value("${onlyoffice.callback-base-url:http://host.docker.internal:48080}")
+ private String onlyOfficeCallbackBaseUrl;
+ @Value("${onlyoffice.jwt-secret:P@ssword25}")
+ private String onlyOfficeJwtSecret;
+
+ /**
+ * 根据文件扩展名获取OnlyOffice文档类型
+ * @param fileExtension 文件扩展名
+ * @return OnlyOffice文档类型
+ */
+ private String getDocumentType(String fileExtension) {
+ if (StrUtil.isEmpty(fileExtension)) {
+ return DOCUMENT_TYPE_TEXT;
+ }
+
+ String ext = fileExtension.toLowerCase();
+
+ // 文本文档类型
+ if (TEXT_DOCUMENT_EXTENSIONS.contains(ext)) {
+ return DOCUMENT_TYPE_TEXT;
+ }
+
+ // 电子表格类型
+ if (SPREADSHEET_EXTENSIONS.contains(ext)) {
+ return DOCUMENT_TYPE_SPREADSHEET;
+ }
+
+ // 演示文稿类型
+ if (PRESENTATION_EXTENSIONS.contains(ext)) {
+ return DOCUMENT_TYPE_PRESENTATION;
+ }
+
+ // 默认返回text类型
+ return DOCUMENT_TYPE_TEXT;
+ }
+
+ /**
+ * 获取默认的文件扩展名,基于请求中的文档类型或文件类型
+ * @param requestedFileType 请求的文件类型
+ * @return 默认的文件扩展名
+ */
+ private String getDefaultFileExtension(String requestedFileType) {
+ if (StrUtil.isNotEmpty(requestedFileType)) {
+ return requestedFileType.toLowerCase();
+ }
+
+ // 默认返回docx(Word文档)
+ return "docx";
+ }
+
+ /**
+ * 验证文件类型是否被OnlyOffice支持
+ * @param fileExtension 文件扩展名
+ * @return 是否支持
+ */
+ private boolean isSupportedFileType(String fileExtension) {
+ if (StrUtil.isEmpty(fileExtension)) {
+ return false;
+ }
+
+ String ext = fileExtension.toLowerCase();
+ return TEXT_DOCUMENT_EXTENSIONS.contains(ext) ||
+ SPREADSHEET_EXTENSIONS.contains(ext) ||
+ PRESENTATION_EXTENSIONS.contains(ext);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long createDocFile(@Valid DocFileCreateReqVO createReqVO) {
+ Long userId = SecurityFrameworkUtils.getLoginUserId();
+ DocFileDO doc = BeanUtils.toBean(createReqVO, DocFileDO.class);
+ doc.setOwnerUserId(userId);
+ doc.setStatus(1);
+
+ // 如果提供了文件信息,使用上传的文件;否则创建空文档
+ if (createReqVO.getFileId() != null) {
+ // 使用已上传的文件
+ doc.setFileId(createReqVO.getFileId());
+ // 从文件类型后缀推断,如果没有提供 fileType
+ if (StrUtil.isEmpty(createReqVO.getFileType())) {
+ try {
+ var fileInfo = fileService.getActiveFileById(createReqVO.getFileId());
+ if (fileInfo != null && StrUtil.isNotEmpty(fileInfo.getName())) {
+ String fileName = fileInfo.getName();
+ if (fileName.contains(".")) {
+ doc.setFileType(fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase());
+ }
+ }
+ } catch (Exception e) {
+ // 如果获取文件信息失败,使用默认值
+ doc.setFileType(getDefaultFileExtension(createReqVO.getFileType()));
+ }
+ } else {
+ doc.setFileType(createReqVO.getFileType());
+ }
+ } else {
+ // 创建空文档
+ doc.setFileId(null);
+ doc.setFileType(getDefaultFileExtension(createReqVO.getFileType()));
+ }
+
+ docFileMapper.insert(doc);
+
+ // 创建初始版本记录
+ DocFileVersionDO version = DocFileVersionDO.builder()
+ .docFileId(doc.getId())
+ .versionNo("v1")
+ .fileId(createReqVO.getFileId()) // 如果是新建文档,这个为null,从上传创建则有值
+ .changeDescription("创建文档")
+ .build();
+ docFileVersionMapper.insert(version);
+ doc.setLatestVersionId(version.getId());
+ docFileMapper.updateById(doc);
+
+ // 添加创建历史
+ docEditHistoryMapper.insert(DocEditHistoryDO.builder()
+ .docFileId(doc.getId()).userId(userId).userName(String.valueOf(userId))
+ .editType(1).description("创建文档").build());
+
+ // 给创建者的角色分配管理权限
+ Set userRoleIds = permissionApi.getUserRoleIdListByUserId(userId).getCheckedData();
+ for (Long roleId : userRoleIds) {
+ DocFilePermissionDO permission = DocFilePermissionDO.builder()
+ .docFileId(doc.getId())
+ .roleId(roleId)
+ .permissionType(DocPermissionTypeEnum.MANAGE.getType())
+ .build();
+ // 检查是否已存在权限记录,避免重复插入
+ DocFilePermissionDO exists = docFilePermissionMapper.selectByDocFileIdAndRoleId(doc.getId(), roleId);
+ if (exists == null) {
+ docFilePermissionMapper.insert(permission);
+ }
+ }
+ return doc.getId();
+ }
+
+ @Override
+ public void updateDocFile(@Valid DocFileUpdateReqVO updateReqVO) {
+ DocFileDO exists = docFileMapper.selectById(updateReqVO.getId());
+ if (exists == null) {
+ throw exception(DOC_NOT_EXISTS);
+ }
+ if (StrUtil.isNotEmpty(updateReqVO.getTitle())) {
+ exists.setTitle(updateReqVO.getTitle());
+ }
+ if (updateReqVO.getDescription() != null) {
+ exists.setDescription(updateReqVO.getDescription());
+ }
+ docFileMapper.updateById(exists);
+ }
+
+ @Override
+ public void deleteDocFile(Long id) {
+ DocFileDO exists = docFileMapper.selectById(id);
+ if (exists == null) {
+ throw exception(DOC_NOT_EXISTS);
+ }
+ docFileMapper.deleteById(id);
+ }
+
+ @Override
+ public DocFileDO getDocFile(Long id) {
+ return docFileMapper.selectById(id);
+ }
+
+ @Override
+ public DocFileRespVO getDocFileWithFileInfo(Long id) {
+ DocFileDO doc = docFileMapper.selectById(id);
+ if (doc == null) {
+ return null;
+ }
+ return buildDocFileRespVO(doc);
+ }
+
+ @Override
+ public PageResult getDocFilePage(DocFilePageReqVO pageReqVO) {
+ return docFileMapper.selectPage(pageReqVO);
+ }
+
+ @Override
+ public PageResult getDocFilePageWithFileInfo(DocFilePageReqVO pageReqVO) {
+ PageResult page = docFileMapper.selectPage(pageReqVO);
+ return new PageResult<>(
+ page.getList().stream().map(this::buildDocFileRespVO).collect(java.util.stream.Collectors.toList()),
+ page.getTotal()
+ );
+ }
+
+ /**
+ * 构建包含文件信息的 DocFileRespVO
+ */
+ private DocFileRespVO buildDocFileRespVO(DocFileDO doc) {
+ DocFileRespVO respVO = DocFileConvert.INSTANCE.convert(doc);
+
+ // 从 fileService 获取文件信息
+ if (doc.getFileId() != null) {
+ try {
+ var fileInfo = fileService.getActiveFileById(doc.getFileId());
+ if (fileInfo != null) {
+ respVO.setFileName(fileInfo.getName());
+ respVO.setFileSize(fileInfo.getSize().longValue());
+
+ // 生成文件访问的JWT token
+ String fileToken = "";
+ if (StrUtil.isNotBlank(onlyOfficeJwtSecret)) {
+ // 创建文件访问token,包含fileId和过期时间(1小时后过期)
+ long expTime = System.currentTimeMillis() / 1000 + 3600; // 1小时后过期
+ fileToken = JWT.create()
+ .setPayload("fileId", doc.getFileId())
+ .setPayload("exp", expTime)
+ .setPayload("iat", System.currentTimeMillis() / 1000)
+ .setKey(onlyOfficeJwtSecret.getBytes())
+ .sign();
+ }
+
+ // 使用新的接口生成文件URL,包含JWT token
+ String fileUrl = StrUtil.removeSuffix(onlyOfficeCallbackBaseUrl, "/") + "/admin-api/infra/doc-file/file-content?fileId=" + doc.getFileId();
+ if (StrUtil.isNotBlank(fileToken)) {
+ fileUrl += "&token=" + fileToken;
+ }
+ respVO.setFileUrl(fileUrl);
+ }
+ } catch (Exception e) {
+ log.warn("获取文件信息失败: docId={}, fileId={}", doc.getId(), doc.getFileId(), e);
+ // 设置默认值
+ respVO.setFileName(doc.getTitle() + "." + doc.getFileType());
+ respVO.setFileSize(0L);
+ respVO.setFileUrl("");
+ }
+ } else {
+ // 空文档
+ respVO.setFileName(doc.getTitle() + "." + doc.getFileType());
+ respVO.setFileSize(0L);
+ respVO.setFileUrl("");
+ }
+
+ return respVO;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long uploadDocFile(MultipartFile file, String title, Integer spaceType, String description) {
+ try (InputStream is = file.getInputStream()) {
+ byte[] content = IoUtil.readBytes(is);
+ String fileName = file.getOriginalFilename();
+ String ext = fileName != null && fileName.contains(".") ?
+ fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase() :
+ getDefaultFileExtension(null);
+
+ // 验证文件类型是否支持
+ if (!isSupportedFileType(ext)) {
+ log.warn("不支持的文件类型: {}, 文件名: {}", ext, fileName);
+ throw new RuntimeException("不支持的文件类型: " + ext + "。支持的文件类型包括:" +
+ TEXT_DOCUMENT_EXTENSIONS + "," + SPREADSHEET_EXTENSIONS + "," + PRESENTATION_EXTENSIONS);
+ }
+
+ String name = title + "." + ext;
+ var saved = fileService.createFileWhitReturn(content, name, "document", file.getContentType(), false);
+ Long userId = SecurityFrameworkUtils.getLoginUserId();
+ DocFileDO doc = DocFileDO.builder()
+ .title(title)
+ .fileId(saved.getId())
+ .fileType(ext)
+ .spaceType(spaceType)
+ .description(description)
+ .ownerUserId(userId)
+ .status(1)
+ .build();
+ docFileMapper.insert(doc);
+ // 版本
+ DocFileVersionDO version = DocFileVersionDO.builder()
+ .docFileId(doc.getId())
+ .versionNo("v1")
+ .fileId(saved.getId())
+ .build();
+ docFileVersionMapper.insert(version);
+ doc.setLatestVersionId(version.getId());
+ docFileMapper.updateById(doc);
+
+ // 给上传者的所有角色分配管理权限
+ Set userRoleIds = permissionApi.getUserRoleIdListByUserId(userId).getCheckedData();
+ for (Long roleId : userRoleIds) {
+ DocFilePermissionDO permission = DocFilePermissionDO.builder()
+ .docFileId(doc.getId())
+ .roleId(roleId)
+ .permissionType(DocPermissionTypeEnum.MANAGE.getType())
+ .build();
+ // 检查是否已存在权限记录,避免重复插入
+ DocFilePermissionDO exists = docFilePermissionMapper.selectByDocFileIdAndRoleId(doc.getId(), roleId);
+ if (exists == null) {
+ docFilePermissionMapper.insert(permission);
+ }
+ }
+
+ docEditHistoryMapper.insert(DocEditHistoryDO.builder()
+ .docFileId(doc.getId()).userId(userId).userName(String.valueOf(userId))
+ .editType(1).description("上传文档").build());
+ return doc.getId();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public DocEditorConfigRespVO getEditorConfig(Long id) {
+ DocFileDO doc = docFileMapper.selectById(id);
+ if (doc == null) {
+ throw exception(DOC_NOT_EXISTS);
+ }
+ Long userId = SecurityFrameworkUtils.getLoginUserId();
+ DocEditorConfigRespVO resp = new DocEditorConfigRespVO();
+
+ // 根据文件类型动态设置文档类型
+ String documentType = getDocumentType(doc.getFileType());
+ resp.setDocumentType(documentType);
+
+ DocEditorConfigRespVO.DocumentConfig document = new DocEditorConfigRespVO.DocumentConfig();
+ document.setFileType(doc.getFileType());
+
+ // 关键修复:使用基于文档版本的稳定key策略
+ // 只有当文档内容真正发生版本变更时,key才会改变
+ String documentKey;
+ if (doc.getLatestVersionId() != null) {
+ // 使用文档ID + 最新版本ID,确保同一版本在编辑期间key保持不变
+ documentKey = doc.getId() + "_v" + doc.getLatestVersionId();
+ } else {
+ // 如果没有版本信息,使用文档ID + 创建时间(不会变化)
+ documentKey = doc.getId() + "_init_" + doc.getCreateTime().toEpochSecond(java.time.ZoneOffset.UTC);
+ }
+
+ document.setKey(documentKey);
+ document.setTitle(doc.getTitle());
+
+ log.info("生成文档key: docId={}, key={}, latestVersionId={}, userId={}",
+ doc.getId(), documentKey, doc.getLatestVersionId(), userId);
+
+ // 使用新的接口生成文件 URL
+ String fileUrl = "";
+ if (doc.getFileId() != null) {
+ // 生成文件访问的JWT token
+ String fileToken = "";
+ if (StrUtil.isNotBlank(onlyOfficeJwtSecret)) {
+ // 创建文件访问token,包含fileId和过期时间(1小时后过期)
+ long expTime = System.currentTimeMillis() / 1000 + 3600; // 1小时后过期
+ fileToken = JWT.create()
+ .setPayload("fileId", doc.getFileId())
+ .setPayload("exp", expTime)
+ .setPayload("iat", System.currentTimeMillis() / 1000)
+ .setKey(onlyOfficeJwtSecret.getBytes())
+ .sign();
+ }
+
+ // 生成通过新接口访问文件的URL,包含JWT token
+ fileUrl = StrUtil.removeSuffix(onlyOfficeCallbackBaseUrl, "/") + "/admin-api/infra/doc-file/file-content?fileId=" + doc.getFileId();
+ if (StrUtil.isNotBlank(fileToken)) {
+ fileUrl += "&token=" + fileToken;
+ }
+ }
+ document.setUrl(fileUrl);
+
+ DocEditorConfigRespVO.Permissions permissions = new DocEditorConfigRespVO.Permissions();
+ permissions.setEdit(hasPermission(id, userId, DocPermissionTypeEnum.EDIT.getType()) || hasPermission(id, userId, DocPermissionTypeEnum.MANAGE.getType()));
+ permissions.setDownload(true);
+ permissions.setPrint(true);
+ document.setPermissions(permissions);
+ resp.setDocument(document);
+
+ DocEditorConfigRespVO.EditorConfig editorConfig = new DocEditorConfigRespVO.EditorConfig();
+ // 在回调URL中添加触发用户ID,确保回调时能准确记录操作用户
+ String callbackUrl = StrUtil.removeSuffix(onlyOfficeCallbackBaseUrl, "/") +
+ "/admin-api/infra/doc-file/callback?docFileId=" + id + "&userId=" + userId;
+ editorConfig.setCallbackUrl(callbackUrl);
+ editorConfig.setLang("zh-CN");
+ editorConfig.setMode(permissions.getEdit() ? "edit" : "view");
+
+ // 重要:设置正确的用户信息以支持多人协作
+ DocEditorConfigRespVO.User user = new DocEditorConfigRespVO.User();
+ user.setId(String.valueOf(userId));
+
+ // 获取真实用户姓名
+ String userName = "User" + userId; // 默认值
+ try {
+ AdminUserRespDTO userInfo = adminUserApi.getUser(userId).getCheckedData();
+ if (userInfo != null && StrUtil.isNotBlank(userInfo.getNickname())) {
+ userName = userInfo.getNickname();
+ }
+ } catch (Exception e) {
+ log.warn("获取用户姓名失败,使用默认值,用户ID: {}", userId, e);
+ }
+ user.setName(userName);
+ editorConfig.setUser(user);
+
+ // 添加协作配置
+ DocEditorConfigRespVO.CoEditing coEditing = new DocEditorConfigRespVO.CoEditing();
+ coEditing.setMode("fast"); // 快速协作模式
+ coEditing.setChange(true); // 允许实时变更
+ editorConfig.setCoEditing(coEditing);
+
+ resp.setEditorConfig(editorConfig);
+ resp.setHeight("100%");
+ resp.setDocumentServerUrl(onlyOfficeBaseUrl);
+
+ // 生成 token(可选 JWT)
+ if (StrUtil.isNotBlank(onlyOfficeJwtSecret)) {
+ // OnlyOffice 标准 JWT payload 格式
+ String token = JWT.create()
+ .setPayload("document", resp.getDocument())
+ .setPayload("documentType", resp.getDocumentType())
+ .setPayload("editorConfig", resp.getEditorConfig())
+ .setPayload("height", resp.getHeight())
+ .setKey(onlyOfficeJwtSecret.getBytes())
+ .sign();
+ resp.setToken(token);
+ } else {
+ // 如果没有 JWT secret,不设置 token,让 OnlyOffice 在无验证模式工作
+ resp.setToken(null);
+ }
+ return resp;
+ }
+
+ @Override
+ public void getDocFileContent(Long fileId, String token, HttpServletResponse response) {
+ try {
+ // 验证JWT token
+ if (StrUtil.isBlank(token)) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ return;
+ }
+
+ // 验证JWT签名和有效性
+ if (StrUtil.isNotBlank(onlyOfficeJwtSecret)) {
+ try {
+ JWT jwt = JWT.of(token).setKey(onlyOfficeJwtSecret.getBytes());
+ if (!jwt.verify()) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ return;
+ }
+
+ // 验证token中的fileId是否匹配
+ Object tokenFileId = jwt.getPayload("fileId");
+ if (tokenFileId == null || !fileId.equals(Long.valueOf(tokenFileId.toString()))) {
+ response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ return;
+ }
+
+ // 验证token是否过期(可选,如果token中包含过期时间)
+ Object expTime = jwt.getPayload("exp");
+ if (expTime != null) {
+ long expTimestamp = Long.parseLong(expTime.toString());
+ if (System.currentTimeMillis() / 1000 > expTimestamp) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ return;
+ }
+ }
+ } catch (Exception e) {
+ log.warn("JWT验证失败: token={}", token, e);
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ return;
+ }
+ }
+
+ // 获取文件信息
+ FileDO file = fileService.getActiveFileById(fileId);
+ if (file == null) {
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
+
+ // 获取文件内容
+ byte[] content = fileService.getFileContent(fileId);
+ if (content == null) {
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
+
+ // 设置响应头
+ response.setContentType(file.getType() != null ? file.getType() : "application/octet-stream");
+ response.setContentLength(content.length);
+ response.setHeader("Content-Disposition", "inline; filename=\"" + file.getName() + "\"");
+ // 添加跨域头,允许 OnlyOffice 访问
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
+ response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
+ response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
+ response.setHeader("Pragma", "no-cache");
+ response.setHeader("Expires", "0");
+
+ // 写入响应
+ try (OutputStream os = response.getOutputStream()) {
+ os.write(content);
+ os.flush();
+ }
+ } catch (Exception e) {
+ log.error("获取文档文件内容失败: fileId={}", fileId, e);
+ response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void saveDocumentContent(Long id, String downloadUrl, String changeDescription, Long triggerUserId) {
+ DocFileDO doc = docFileMapper.selectById(id);
+ if (doc == null) {
+ throw exception(DOC_NOT_EXISTS);
+ }
+
+ // 获取当前登录用户ID(在回调场景下,已经在Controller中设置了正确的用户上下文)
+ Long userId = SecurityFrameworkUtils.getLoginUserId();
+ if (userId == null) {
+ // 兜底:使用系统默认用户ID(1表示系统用户)
+ userId = 1L;
+ log.warn("无法获取登录用户ID,使用系统用户ID作为默认值: {}", userId);
+ }
+
+ log.info("开始保存 OnlyOffice 文档: docId={}, userId={}, downloadUrl={}, changeDescription={}",
+ id, userId, downloadUrl, changeDescription);
+
+ try {
+ // 验证下载 URL 格式
+ if (StrUtil.isBlank(downloadUrl) || !downloadUrl.startsWith("http")) {
+ log.error("无效的下载 URL: docId={}, url={}", id, downloadUrl);
+ throw new RuntimeException("无效的下载 URL: " + downloadUrl);
+ }
+
+ // 下载 OnlyOffice 提供的最新文件内容
+ byte[] newContent;
+ try {
+ log.info("开始下载文档内容: url={}", downloadUrl);
+ newContent = HttpUtil.downloadBytes(downloadUrl); // 30秒超时
+ log.info("成功下载文档内容: size={} bytes", newContent.length);
+ } catch (Exception e) {
+ log.error("下载文档内容失败: url={}", downloadUrl, e);
+ throw new RuntimeException("下载文档内容失败: " + e.getMessage(), e);
+ }
+
+ if (newContent == null || newContent.length == 0) {
+ log.error("下载的文档内容为空: url={}", downloadUrl);
+ throw new RuntimeException("下载的文档内容为空");
+ }
+
+ // 优化:检查内容是否与当前版本相同,避免创建重复版本
+ boolean contentChanged = true;
+ if (doc.getFileId() != null) {
+ try {
+ FileDO currentFile = fileService.getActiveFileById(doc.getFileId());
+ if (currentFile != null) {
+ // 获取当前文件的内容
+ byte[] currentContent = fileService.getFileContent(doc.getFileId());
+ if (currentContent != null && currentContent.length == newContent.length) {
+ // 简单的内容比较,如果完全相同则跳过版本创建
+ contentChanged = !java.util.Arrays.equals(currentContent, newContent);
+ log.info("内容比较结果: docId={}, contentChanged={}, oldSize={}, newSize={}",
+ id, contentChanged, currentContent.length, newContent.length);
+ }
+ }
+ } catch (Exception e) {
+ log.warn("无法比较文档内容,将继续创建新版本: docId={}", id, e);
+ }
+ }
+
+ if (!contentChanged) {
+ log.info("文档内容未发生变化,跳过版本创建: docId={}", id);
+ // 通过WebSocket通知,但不创建新版本
+ DocWebSocketHandler.notifyDocSaveStatus(String.valueOf(id), "success",
+ "文档已保存(内容未变更)");
+ return;
+ }
+
+ // 重新上传生成新的文件 (保持扩展名)
+ String ext = doc.getFileType();
+ String newName = doc.getTitle() + "-" + System.currentTimeMillis() + "." + ext;
+
+ try {
+ var saved = fileService.createFileWhitReturn(newContent, newName, "document/onlyoffice", null, false);
+ log.info("成功保存新文件版本: docId={}, fileId={}", id, saved.getId());
+
+ // 获取当前文档的版本数量以生成正确的版本号
+ Long versionCount = docFileVersionMapper.selectCountByDocFileId(id);
+ String versionNo = "v" + (versionCount + 1);
+
+ DocFileVersionDO version = DocFileVersionDO.builder()
+ .docFileId(id)
+ .versionNo(versionNo)
+ .fileId(saved.getId())
+ .changeDescription(StrUtil.emptyToDefault(changeDescription, "自动保存"))
+ .build();
+
+ // 手动设置创建者和更新者,解决回调场景下用户ID为空的问题
+ version.setCreator(userId.toString());
+ version.setUpdater(userId.toString());
+ version.setCreateTime(LocalDateTime.now());
+ version.setUpdateTime(LocalDateTime.now());
+
+ docFileVersionMapper.insert(version);
+
+ // 更新主表指向最新文件
+ doc.setLatestVersionId(version.getId());
+ doc.setFileId(saved.getId());
+ docFileMapper.updateById(doc);
+
+ // 记录历史 - 获取真实用户名
+ String userName = "系统用户"; // 默认值
+ if (userId > 1) { // 非系统用户时获取真实用户名
+ try {
+ AdminUserRespDTO userInfo = adminUserApi.getUser(userId).getCheckedData();
+ if (userInfo != null && StrUtil.isNotBlank(userInfo.getNickname())) {
+ userName = userInfo.getNickname();
+ } else {
+ userName = "User" + userId;
+ }
+ } catch (Exception e) {
+ log.warn("获取用户姓名失败,使用默认值,用户ID: {}", userId, e);
+ userName = "User" + userId;
+ }
+ }
+
+ DocEditHistoryDO editHistory = DocEditHistoryDO.builder()
+ .docFileId(id)
+ .userId(userId)
+ .userName(userName)
+ .editType(2) // 编辑类型:编辑
+ .description("OnlyOffice回调保存: " + changeDescription)
+ .build();
+ docEditHistoryMapper.insert(editHistory);
+
+ log.info("文档保存完成: docId={}, versionId={}, versionNo={}", id, version.getId(), versionNo);
+
+ // 通过WebSocket通知所有协作用户保存成功
+ DocWebSocketHandler.notifyDocSaveStatus(String.valueOf(id), "success",
+ "文档保存成功: " + versionNo + " - " + changeDescription);
+
+ } catch (Exception e) {
+ log.error("保存文件版本失败: docId={}", id, e);
+
+ // 通过WebSocket通知保存失败
+ DocWebSocketHandler.notifyDocSaveStatus(String.valueOf(id), "error",
+ "文档保存失败: " + e.getMessage());
+
+ throw new RuntimeException("保存文件版本失败: " + e.getMessage(), e);
+ }
+ } catch (Exception e) {
+ log.error("保存 OnlyOffice 文档失败 docId={} url={}", id, downloadUrl, e);
+
+ // 通过WebSocket通知保存失败
+ DocWebSocketHandler.notifyDocSaveStatus(String.valueOf(id), "error",
+ "文档保存失败: " + e.getMessage());
+
+ throw new RuntimeException("保存文档失败: " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void setDocFilePermission(@Valid DocFilePermissionReqVO permissionReqVO) {
+ DocFilePermissionDO exists = docFilePermissionMapper.selectByDocFileIdAndRoleId(permissionReqVO.getDocFileId(), permissionReqVO.getRoleId());
+ if (exists == null) {
+ exists = BeanUtils.toBean(permissionReqVO, DocFilePermissionDO.class);
+ docFilePermissionMapper.insert(exists);
+ } else {
+ exists.setPermissionType(permissionReqVO.getPermissionType());
+ exists.setExpireTime(permissionReqVO.getExpireTime());
+ docFilePermissionMapper.updateById(exists);
+ }
+ }
+
+ @Override
+ public List getDocFilePermissions(Long docFileId) {
+ return docFilePermissionMapper.selectListByDocFileId(docFileId);
+ }
+
+ @Override
+ public void deleteDocFilePermission(Long docFileId, Long roleId) {
+ DocFilePermissionDO exists = docFilePermissionMapper.selectByDocFileIdAndRoleId(docFileId, roleId);
+ if (exists != null) {
+ docFilePermissionMapper.deleteById(exists.getId());
+ }
+ }
+
+ @Override
+ public boolean hasPermission(Long docFileId, Long userId, Integer permissionType) {
+ // 1. 超级管理员拥有所有权限
+ Set userRoleIds = permissionApi.getUserRoleIdListByUserId(userId).getCheckedData();
+
+ if (roleApi.hasAnySuperAdmin(userRoleIds).getCheckedData()) {
+ return true;
+ }
+
+ // 2. 检查是否为文档拥有者
+ DocFileDO docFile = docFileMapper.selectById(docFileId);
+ if (docFile != null && userId.equals(docFile.getOwnerUserId())) {
+ return true;
+ }
+
+ // 3. 检查用户角色权限
+ List rolePermissions = docFilePermissionMapper.selectListByRoleIds(new ArrayList<>(userRoleIds));
+
+ for (DocFilePermissionDO permission : rolePermissions) {
+ if (Objects.equals(permission.getDocFileId(), docFileId)) {
+ // 检查是否过期
+ if (permission.getExpireTime() != null && permission.getExpireTime().isBefore(LocalDateTime.now())) {
+ continue;
+ }
+
+ // 管理权限拥有所有权限
+ if (permission.getPermissionType().equals(DocPermissionTypeEnum.MANAGE.getType())) {
+ return true;
+ }
+
+ // 检查具体权限类型
+ if (permission.getPermissionType().equals(permissionType)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ public List getDocFileVersions(Long docFileId) {
+ return docFileVersionMapper.selectListByDocFileId(docFileId);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void restoreDocFileToVersion(Long docFileId, Long versionId) {
+ DocFileDO doc = docFileMapper.selectById(docFileId);
+ if (doc == null) {
+ throw exception(DOC_NOT_EXISTS);
+ }
+
+ DocFileVersionDO version = docFileVersionMapper.selectById(versionId);
+ if (version == null || !version.getDocFileId().equals(docFileId)) {
+ throw new RuntimeException("版本不存在或不属于该文档");
+ }
+
+ Long userId = SecurityFrameworkUtils.getLoginUserId();
+
+ // 创建新版本记录(恢复操作也是一个新版本)
+ Long versionCount = docFileVersionMapper.selectCountByDocFileId(docFileId);
+ String newVersionNo = "v" + (versionCount + 1);
+
+ DocFileVersionDO newVersion = DocFileVersionDO.builder()
+ .docFileId(docFileId)
+ .versionNo(newVersionNo)
+ .fileId(version.getFileId()) // 使用被恢复版本的文件ID
+ .changeDescription("恢复到版本 " + version.getVersionNo())
+ .build();
+ docFileVersionMapper.insert(newVersion);
+
+ // 更新主表
+ doc.setLatestVersionId(newVersion.getId());
+ doc.setFileId(version.getFileId());
+ docFileMapper.updateById(doc);
+
+ // 记录历史
+ docEditHistoryMapper.insert(DocEditHistoryDO.builder()
+ .docFileId(docFileId).userId(userId).userName(String.valueOf(userId))
+ .editType(2).description("恢复到版本 " + version.getVersionNo())
+ .build());
+
+ log.info("文档版本恢复完成: docId={}, 恢复到版本={}, 新版本={}",
+ docFileId, version.getVersionNo(), newVersionNo);
+ }
+}
+
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java
index d84fee6d..318156ba 100644
--- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java
@@ -86,6 +86,14 @@ public interface FileService {
*/
byte[] getFileContent(Long configId, String path) throws Exception;
+ /**
+ * 默认从主配置中根据 FileId 读取文件内容
+ *
+ * @param fileId 文件编号
+ * @return 文件内容
+ */
+ byte[] getFileContent(Long fileId) throws Exception;
+
/**
* 根据 fileId 查询生效中的 FileDO
*
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java
index 70069904..4d6363ab 100644
--- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java
@@ -271,6 +271,14 @@ public class FileServiceImpl implements FileService {
Assert.notNull(client, "客户端({}) 不能为空", configId);
return client.getContent(path);
}
+
+ @Override
+ public byte[] getFileContent(Long fileId) throws Exception {
+ FileClient masterFileClient = fileConfigService.getMasterFileClient();
+ FileDO fileDO = fileMapper.selectById(fileId);
+ return masterFileClient.getContent(fileDO.getPath());
+ }
+
@Override
public FileDO getActiveFileById(Long fileId) {
// 由于 FileDO 没有状态字段,直接查主键即为生效中的文件
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/websocket/DocWebSocketHandler.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/websocket/DocWebSocketHandler.java
new file mode 100644
index 00000000..86f679c0
--- /dev/null
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/websocket/DocWebSocketHandler.java
@@ -0,0 +1,191 @@
+package cn.iocoder.yudao.module.infra.websocket;
+
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.fastjson2.JSON;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.socket.*;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 文档协作WebSocket处理器
+ *
+ * @author 芋道源码
+ */
+@Component
+@Slf4j
+public class DocWebSocketHandler implements WebSocketHandler {
+
+ /**
+ * 存储文档ID -> WebSocket会话映射
+ */
+ private static final Map> DOC_SESSIONS = new ConcurrentHashMap<>();
+
+ /**
+ * 存储会话ID -> 文档ID映射
+ */
+ private static final Map SESSION_TO_DOC = new ConcurrentHashMap<>();
+
+ @Override
+ public void afterConnectionEstablished(WebSocketSession session) throws Exception {
+ String docId = getDocIdFromSession(session);
+ if (StrUtil.isNotBlank(docId)) {
+ // 将会话添加到对应文档的会话组
+ DOC_SESSIONS.computeIfAbsent(docId, k -> new ConcurrentHashMap<>())
+ .put(session.getId(), session);
+ SESSION_TO_DOC.put(session.getId(), docId);
+
+ log.info("文档协作WebSocket连接建立: docId={}, sessionId={}", docId, session.getId());
+
+ // 向客户端发送连接成功消息
+ sendMessage(session, Map.of(
+ "type", "connected",
+ "docId", docId,
+ "message", "文档协作连接已建立"
+ ));
+
+ // 通知其他用户有新用户加入
+ broadcastToDoc(docId, Map.of(
+ "type", "user_joined",
+ "sessionId", session.getId(),
+ "message", "有新用户加入协作"
+ ), session.getId());
+ }
+ }
+
+ @Override
+ public void handleMessage(WebSocketSession session, WebSocketMessage> message) throws Exception {
+ try {
+ String payload = message.getPayload().toString();
+ Map data = JSON.parseObject(payload, Map.class);
+ String type = (String) data.get("type");
+ String docId = SESSION_TO_DOC.get(session.getId());
+
+ log.debug("收到WebSocket消息: docId={}, sessionId={}, type={}", docId, session.getId(), type);
+
+ switch (type) {
+ case "heartbeat":
+ // 心跳保持连接
+ sendMessage(session, Map.of("type", "heartbeat_ack"));
+ break;
+ default:
+ log.debug("WebSocket消息: docId={}, type={}", docId, type);
+ }
+ } catch (Exception e) {
+ log.error("处理WebSocket消息失败: sessionId={}", session.getId(), e);
+ }
+ }
+
+ @Override
+ public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
+ log.error("WebSocket传输错误: sessionId={}", session.getId(), exception);
+ }
+
+ @Override
+ public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
+ String docId = SESSION_TO_DOC.remove(session.getId());
+ if (StrUtil.isNotBlank(docId)) {
+ Map docSessions = DOC_SESSIONS.get(docId);
+ if (docSessions != null) {
+ docSessions.remove(session.getId());
+ if (docSessions.isEmpty()) {
+ DOC_SESSIONS.remove(docId);
+ }
+
+ // 通知其他用户有用户离开
+ broadcastToDoc(docId, Map.of(
+ "type", "user_left",
+ "sessionId", session.getId(),
+ "message", "有用户离开协作"
+ ), null);
+ }
+ }
+
+ log.info("文档协作WebSocket连接关闭: docId={}, sessionId={}, status={}",
+ docId, session.getId(), closeStatus);
+ }
+
+ @Override
+ public boolean supportsPartialMessages() {
+ return false;
+ }
+
+ /**
+ * 从会话中获取文档ID
+ */
+ private String getDocIdFromSession(WebSocketSession session) {
+ String query = session.getUri().getQuery();
+ if (StrUtil.isNotBlank(query)) {
+ String[] params = query.split("&");
+ for (String param : params) {
+ if (param.startsWith("docId=")) {
+ return param.substring(6);
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 向指定会话发送消息
+ */
+ private void sendMessage(WebSocketSession session, Object message) {
+ try {
+ if (session.isOpen()) {
+ session.sendMessage(new TextMessage(JSON.toJSONString(message)));
+ }
+ } catch (IOException e) {
+ log.error("发送WebSocket消息失败: sessionId={}", session.getId(), e);
+ }
+ }
+
+ /**
+ * 向文档的所有会话广播消息
+ */
+ private void broadcastToDoc(String docId, Object message, String excludeSessionId) {
+ Map docSessions = DOC_SESSIONS.get(docId);
+ if (docSessions != null) {
+ docSessions.forEach((sessionId, session) -> {
+ if (!sessionId.equals(excludeSessionId)) {
+ sendMessage(session, message);
+ }
+ });
+ }
+ }
+
+ /**
+ * 静态方法:向指定文档的所有会话发送保存状态通知
+ */
+ public static void notifyDocSaveStatus(String docId, String status, String message) {
+ Map docSessions = DOC_SESSIONS.get(docId);
+ if (docSessions != null) {
+ Map notification = Map.of(
+ "type", "save_status",
+ "status", status,
+ "message", message,
+ "timestamp", System.currentTimeMillis()
+ );
+
+ docSessions.values().forEach(session -> {
+ try {
+ if (session.isOpen()) {
+ session.sendMessage(new TextMessage(JSON.toJSONString(notification)));
+ }
+ } catch (IOException e) {
+ log.error("发送保存状态通知失败: sessionId={}", session.getId(), e);
+ }
+ });
+ }
+ }
+
+ /**
+ * 获取指定文档的在线用户数
+ */
+ public static int getOnlineUserCount(String docId) {
+ Map docSessions = DOC_SESSIONS.get(docId);
+ return docSessions != null ? docSessions.size() : 0;
+ }
+}
diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/resources/application.yaml b/yudao-module-infra/yudao-module-infra-server/src/main/resources/application.yaml
index 115dfd3f..66242b35 100644
--- a/yudao-module-infra/yudao-module-infra-server/src/main/resources/application.yaml
+++ b/yudao-module-infra/yudao-module-infra-server/src/main/resources/application.yaml
@@ -186,7 +186,17 @@ yudao:
tenant: # 多租户相关配置项
enable: true
ignore-urls:
+ - /admin-api/infra/doc-file/file-content
+ - /admin-api/infra/doc-file/callback* # OnlyOffice回调接口
ignore-tables:
- infra_std_name
+ - infra_doc_file
+ - infra_doc_file_version
debug: false
+
+# OnlyOffice 配置
+onlyoffice:
+ base-url: http://172.16.46.63:30085 # OnlyOffice 服务地址
+ callback-base-url: http://172.16.46.62:30081 # 应用服务回调地址,OnlyOffice需要能访问到
+ jwt-secret: P@ssword25 # JWT 密钥
diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/permission/PermissionApi.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/permission/PermissionApi.java
index 45927f91..ffb5b1b0 100644
--- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/permission/PermissionApi.java
+++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/permission/PermissionApi.java
@@ -24,4 +24,9 @@ public interface PermissionApi extends PermissionCommonApi {
@Parameter(name = "roleIds", description = "角色编号集合", example = "1,2", required = true)
CommonResult> getUserRoleIdListByRoleIds(@RequestParam("roleIds") Collection roleIds);
+ @GetMapping(PREFIX + "/user-role-id-list-by-user-id")
+ @Operation(summary = "获得用户拥有的角色编号集合")
+ @Parameter(name = "userId", description = "用户编号", example = "1", required = true)
+ CommonResult> getUserRoleIdListByUserId(@RequestParam("userId") Long userId);
+
}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/permission/RoleApi.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/permission/RoleApi.java
index 33c6fa23..a1676f6c 100644
--- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/permission/RoleApi.java
+++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/permission/RoleApi.java
@@ -22,4 +22,9 @@ public interface RoleApi {
@Parameter(name = "ids", description = "角色编号数组", example = "1,2", required = true)
CommonResult validRoleList(@RequestParam("ids") Collection ids);
+ @GetMapping(PREFIX + "/has-any-super-admin")
+ @Operation(summary = "判断角色列表中是否有超级管理员")
+ @Parameter(name = "roleIds", description = "角色编号集合", example = "1,2", required = true)
+ CommonResult hasAnySuperAdmin(@RequestParam("roleIds") Collection roleIds);
+
}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/api/permission/PermissionApiImpl.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/api/permission/PermissionApiImpl.java
index 7cfa7f3f..0327e02a 100644
--- a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/api/permission/PermissionApiImpl.java
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/api/permission/PermissionApiImpl.java
@@ -26,6 +26,11 @@ public class PermissionApiImpl implements PermissionApi {
return success(permissionService.getUserRoleIdListByRoleId(roleIds));
}
+ @Override
+ public CommonResult> getUserRoleIdListByUserId(Long userId) {
+ return success(permissionService.getUserRoleIdListByUserIdFromCache(userId));
+ }
+
@Override
public CommonResult hasAnyPermissions(Long userId, String... permissions) {
return success(permissionService.hasAnyPermissions(userId, permissions));
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/api/permission/RoleApiImpl.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/api/permission/RoleApiImpl.java
index f7f5dd98..55e4e188 100644
--- a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/api/permission/RoleApiImpl.java
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/api/permission/RoleApiImpl.java
@@ -22,4 +22,9 @@ public class RoleApiImpl implements RoleApi {
roleService.validateRoleList(ids);
return success(true);
}
+
+ @Override
+ public CommonResult hasAnySuperAdmin(Collection roleIds) {
+ return success(roleService.hasAnySuperAdmin(roleIds));
+ }
}
diff --git a/yudao-server/src/main/resources/application.yaml b/yudao-server/src/main/resources/application.yaml
index 5e5a67d5..97236661 100644
--- a/yudao-server/src/main/resources/application.yaml
+++ b/yudao-server/src/main/resources/application.yaml
@@ -301,6 +301,9 @@ yudao:
enable: true
ignore-urls:
- /jmreport/* # 积木报表,无法携带租户编号
+ - /admin-api/infra/doc-file/file-content
+ - /admin-api/infra/doc-file/callback # OnlyOffice回调接口
+ - /admin-api/infra/doc-file/callback?* # OnlyOffice回调接口(带参数)
ignore-visit-urls:
- /admin-api/system/user/profile/**
- /admin-api/system/auth/**
@@ -344,6 +347,13 @@ yudao:
customer: E77DF18BE109F454A5CD319E44BF5177
debug: false
+
+# OnlyOffice 配置
+onlyoffice:
+ base-url: http://172.16.46.63:30085 # OnlyOffice 服务地址
+ callback-base-url: http://172.16.46.62:30081 # 应用服务回调地址,OnlyOffice需要能访问到
+ jwt-secret: P@ssword25 # JWT 密钥
+
# 插件配置 TODO 芋艿:【IOT】需要处理下
pf4j:
pluginsDir: /Users/anhaohao/code/gitee/ruoyi-vue-pro/plugins # 插件目录
\ No newline at end of file