1. 新增在线协同文档功能 v 1

This commit is contained in:
chenbowen
2025-09-01 10:01:35 +08:00
parent 827ab022ed
commit 1ed31a4f49
43 changed files with 2734 additions and 3 deletions

View File

@@ -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, "文档不存在");
}

View File

@@ -32,6 +32,12 @@
<version>${revision}</version>
</dependency>
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-module-system-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 业务组件 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>

View File

@@ -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支持
}
}

View File

@@ -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<Long> createDocFile(@Valid @RequestBody DocFileCreateReqVO createReqVO) {
return success(docFileService.createDocFile(createReqVO));
}
@PutMapping("/update")
@Operation(summary = "修改在线文档")
@PreAuthorize("@ss.hasPermission('infra:doc:update')")
public CommonResult<Boolean> updateDocFile(@Valid @RequestBody DocFileUpdateReqVO updateReqVO) {
docFileService.updateDocFile(updateReqVO);
return success(true);
}
@DeleteMapping("/delete")
@Operation(summary = "删除在线文档")
@PreAuthorize("@ss.hasPermission('infra:doc:delete')")
public CommonResult<Boolean> deleteDocFile(@RequestParam("id") Long id) {
docFileService.deleteDocFile(id);
return success(true);
}
@GetMapping("/get")
@Operation(summary = "获得在线文档")
@PreAuthorize("@ss.hasPermission('infra:doc:query')")
public CommonResult<DocFileRespVO> 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<PageResult<DocFileRespVO>> getDocFilePage(@Valid DocFilePageReqVO pageReqVO) {
PageResult<DocFileRespVO> page = docFileService.getDocFilePageWithFileInfo(pageReqVO);
return success(page);
}
@PostMapping("/upload")
@Operation(summary = "上传文档文件")
@PreAuthorize("@ss.hasPermission('infra:doc:upload')")
public CommonResult<Long> 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<DocEditorConfigRespVO> 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<String,Object> callback(@RequestParam("docFileId") Long docFileId,
@RequestParam(value = "userId", required = false) Long triggerUserId,
@RequestBody Map<String,Object> 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<Boolean> setPermission(@Valid @RequestBody DocFilePermissionReqVO permissionReqVO) {
docFileService.setDocFilePermission(permissionReqVO);
return success(true);
}
@GetMapping("/permission/list")
@Operation(summary = "获得文档权限列表")
@PreAuthorize("@ss.hasPermission('infra:doc:permission')")
public CommonResult<List<DocFilePermissionRespVO>> getPermissionList(@RequestParam("docFileId") Long docFileId) {
List<DocFilePermissionDO> list = docFileService.getDocFilePermissions(docFileId);
return success(DocFileConvert.INSTANCE.convertPermissionList(list));
}
@DeleteMapping("/permission/delete")
@Operation(summary = "删除文档权限")
@PreAuthorize("@ss.hasPermission('infra:doc:permission')")
public CommonResult<Boolean> 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<List<DocFileVersionRespVO>> getVersionList(@RequestParam("docFileId") Long docFileId) {
List<DocFileVersionDO> list = docFileService.getDocFileVersions(docFileId);
List<DocFileVersionRespVO> 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<Boolean> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<DocFileRespVO> convertList(List<DocFileDO> list);
PageResult<DocFileRespVO> convertPage(PageResult<DocFileDO> page);
DocFilePermissionDO convert(DocFilePermissionReqVO bean);
DocFilePermissionRespVO convert(DocFilePermissionDO bean);
List<DocFilePermissionRespVO> convertPermissionList(List<DocFilePermissionDO> list);
DocFileVersionRespVO convert(DocFileVersionDO bean);
List<DocFileVersionRespVO> convertVersionList(List<DocFileVersionDO> list);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<DocEditHistoryDO> {
default List<DocEditHistoryDO> selectListByDocFileId(Long docFileId) {
return selectList(new LambdaQueryWrapperX<DocEditHistoryDO>()
.eq(DocEditHistoryDO::getDocFileId, docFileId)
.orderByDesc(DocEditHistoryDO::getCreateTime));
}
}

View File

@@ -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<DocFileDO> {
default PageResult<DocFileDO> selectPage(DocFilePageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<DocFileDO>()
.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));
}
}

View File

@@ -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<DocFilePermissionDO> {
default List<DocFilePermissionDO> selectListByDocFileId(Long docFileId) {
return selectList(new LambdaQueryWrapperX<DocFilePermissionDO>()
.eq(DocFilePermissionDO::getDocFileId, docFileId));
}
default DocFilePermissionDO selectByDocFileIdAndRoleId(Long docFileId, Long roleId) {
return selectOne(new LambdaQueryWrapperX<DocFilePermissionDO>()
.eq(DocFilePermissionDO::getDocFileId, docFileId)
.eq(DocFilePermissionDO::getRoleId, roleId));
}
default List<DocFilePermissionDO> selectListByRoleId(Long roleId) {
return selectList(new LambdaQueryWrapperX<DocFilePermissionDO>()
.eq(DocFilePermissionDO::getRoleId, roleId));
}
default List<DocFilePermissionDO> selectListByRoleIds(List<Long> roleIds) {
return selectList(new LambdaQueryWrapperX<DocFilePermissionDO>()
.in(DocFilePermissionDO::getRoleId, roleIds));
}
}

View File

@@ -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<DocFileVersionDO> {
default List<DocFileVersionDO> selectListByDocFileId(Long docFileId) {
return selectList(new LambdaQueryWrapperX<DocFileVersionDO>()
.eq(DocFileVersionDO::getDocFileId, docFileId)
.orderByDesc(DocFileVersionDO::getId));
}
default DocFileVersionDO selectLatestByDocFileId(Long docFileId) {
return selectOne(new LambdaQueryWrapperX<DocFileVersionDO>()
.eq(DocFileVersionDO::getDocFileId, docFileId)
.orderByDesc(DocFileVersionDO::getId)
.last("LIMIT 1"));
}
default Long selectCountByDocFileId(Long docFileId) {
return selectCount(new LambdaQueryWrapperX<DocFileVersionDO>()
.eq(DocFileVersionDO::getDocFileId, docFileId));
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 {
}

View File

@@ -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 服务的安全配置

View File

@@ -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<DocFileDO> getDocFilePage(DocFilePageReqVO pageReqVO);
/**
* 获得在线文档分页(包含文件信息)
*
* @param pageReqVO 分页查询
* @return 在线文档分页
*/
PageResult<DocFileRespVO> 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<DocFilePermissionDO> 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<DocFileVersionDO> getDocFileVersions(Long docFileId);
/**
* 恢复到指定版本
*
* @param docFileId 文档编号
* @param versionId 版本编号
*/
void restoreDocFileToVersion(Long docFileId, Long versionId);
}

View File

@@ -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();
}
// 默认返回docxWord文档
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<Long> 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<DocFileDO> getDocFilePage(DocFilePageReqVO pageReqVO) {
return docFileMapper.selectPage(pageReqVO);
}
@Override
public PageResult<DocFileRespVO> getDocFilePageWithFileInfo(DocFilePageReqVO pageReqVO) {
PageResult<DocFileDO> 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<Long> 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) {
// 兜底使用系统默认用户ID1表示系统用户
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<DocFilePermissionDO> 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<Long> 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<DocFilePermissionDO> 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<DocFileVersionDO> 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);
}
}

View File

@@ -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
*

View File

@@ -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 没有状态字段,直接查主键即为生效中的文件

View File

@@ -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<String, Map<String, WebSocketSession>> DOC_SESSIONS = new ConcurrentHashMap<>();
/**
* 存储会话ID -> 文档ID映射
*/
private static final Map<String, String> 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<String, Object> 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<String, WebSocketSession> 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<String, WebSocketSession> 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<String, WebSocketSession> docSessions = DOC_SESSIONS.get(docId);
if (docSessions != null) {
Map<String, Object> 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<String, WebSocketSession> docSessions = DOC_SESSIONS.get(docId);
return docSessions != null ? docSessions.size() : 0;
}
}

View File

@@ -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 密钥