Dark Dwarf Blog background

OnlyOffice 编辑器实现记录

OnlyOffice 编辑器实现记录

在一个项目中引入了 OnlyOffice 作为 office 编辑器,这里记录一下实现过程和一些配置上的坑。

1. 总体架构

OnlyOffice 服务

后端

前端

交互

交互

React App

浏览器打开
OnlyOffice 编辑器

Node.js API

JWT 验证
文件权限管理
生成安全 URL

OnlyOffice
Document Server

文档处理
协作编辑
格式转换

我们使用自部署的 OnlyOffice Server,使用 docker-compose.yml 启动 server。

总体的文档编辑流程如下:

成功

失败

用户请求

Backend验证

权限验证

生成JWT
15分钟

拒绝访问

构建带token的URL

OnlyOffice
请求文件

Backend验证token
传输文件

开始编辑

2. 后端配置

a.a. OnlyOffice 配置生成

首先先为 OnlyOffice 分别生成用于访问和回调的 JWT Token(如果不使用 JWT Token 访问的话会报错说不安全),使用 Token 构造对应的 URL:

const officeToken = jwt.sign(
  {
    id: userId,
    email: userEmail,
    fileId,
    purpose: "office-content",
  },
  config.jwtSecret,
  { expiresIn: "15m" },
);

// 通过参数传递 token
const officeContentUrl = `${config.officeCallbackUrl}/api/files/${fileId}/office-content?token=${officeToken}`;

// 生成回调 token(较长有效期,编辑期间需持续有效)
const callbackToken = jwt.sign(
  {
    id: userId,
    email: userEmail,
    fileId,
    purpose: "office-callback",
  },
  config.jwtSecret,
  { expiresIn: "24h" },
);

const callbackUrl = `${config.officeCallbackUrl}/api/files/${fileId}/office-callback?token=${callbackToken}`;

然后按照文档生成配置,并随 Token 一起返回:

const documentConfig = {
  document: {
    fileType: this.getFileType(file.name),
    key: `${fileId}_${file.updatedAt.getTime()}`, // 只在更新时变化的 key
    title: file.name,
    url: officeContentUrl,
    permissions: {
      comment: true,
      copy: true,
      download: true,
      edit: true,
      fillForms: true,
      modifyContentControl: true,
      modifyFilter: true,
      print: true,
      review: true,
    },
  },
  documentType: this.getDocumentType(file.name),
  editorConfig: {
    callbackUrl,
    user: {
      id: userId,
      name: userEmail,
    },
    customization: {
      autosave: true,
      forcesave: true,
    },
  },
};

const response: any = {
  url: officeContentUrl,
  config: documentConfig,
  token: onlyofficeToken;
};

return response;

b.b. 使用回调响应 Save 等需求

OnlyOffice 的回调所需参数如下(没找到类型定义就直接写了):

callbackBody: {
  status: number;
  url?: string;
  key?: string;
  users?: string[];
  actions?: Array<{ type: number; userid: string }>;
  forcesavetype?: number;
  userdata?: string;
};

由于 OnlyOffice 是放在 Docker 中的,简单处理一下确保可以访问:

if ((status === 2 || status === 6) && url) {
  try {
    // 从 OnlyOffice Document Server 下载修改后的文档
    // OnlyOffice 返回的 URL 可能包含 localhost,需要在 Docker 环境中转换为容器名
    let downloadUrl = url;

    // 在 Docker 环境中,将 localhost 替换为 OnlyOffice 容器名
    // 处理各种可能的格式:localhost, localhost:80, localhost:8080, 127.0.0.1
    if (downloadUrl.includes("://localhost:8080")) {
      // 宿主机8080映射到容器内部80
      downloadUrl = downloadUrl.replace(
        "://localhost:8080",
        "://gdrive-onlyoffice",
      );
    } else if (downloadUrl.includes("://localhost:80")) {
      downloadUrl = downloadUrl.replace(
        "://localhost:80",
        "://gdrive-onlyoffice",
      );
    } else if (downloadUrl.includes("://localhost")) {
      downloadUrl = downloadUrl.replace(
        "://localhost",
        "://gdrive-onlyoffice",
      );
    } else if (downloadUrl.includes("://127.0.0.1")) {
      downloadUrl = downloadUrl.replace(
        "://127.0.0.1",
        "://gdrive-onlyoffice",
      );
    }
  }
}

然后直接 fetch 就行了:

const response = await fetch(downloadUrl);

c.c. OnlyOffice 流式预览 API

需要专门为 OnlyOffice 提供流式预览 API、让 Docker 中的 OnlyOffice Server 能够访问文件内容。做 Token 校验后使用普通文件 Preview 即可:

async serveOfficeContent(req: Request, res: Response, next: NextFunction) {
  const token = req.query.token as string;
  if (!token) {
    throw new AppError(StatusCodes.UNAUTHORIZED, "Token is required");
  }

  const payload = this.fileService.verifyOfficeContentToken(token);

  if (
    payload.purpose !== "office-content" ||
    payload.fileId !== req.params.fileId
  ) {
    throw new AppError(StatusCodes.FORBIDDEN, "Token mismatch");
  }

  const result = await this.fileService.getPreviewStream({
    userId: payload.id,
    fileId: payload.fileId,
  });

  res.setHeader("Content-Type", result.mimeType);
  res.setHeader("Content-Length", result.size);
  res.setHeader(
    "Content-Disposition",
    `inline; filename="${encodeURIComponent(result.fileName)}"`,
  );
  res.setHeader("Cache-Control", "private, max-age=900");

  result.stream.pipe(res);
  result.stream.on("error", (error) => {
    console.error("Office content stream error:", error);
    if (!res.headersSent) {
      throw new AppError(
        StatusCodes.INTERNAL_SERVER_ERROR,
        "Failed to stream file",
      );
    }
  });
}

2. 前端使用 OnlyOffice 组件

a.a. 配置加载

调用 backend 获取 config 即可:

const [result, setResult] = useState<OnlyOfficeResult | null>(null);

try {
  const officeData = await fileService.getOfficeContentUrl(fileId);
  setResult({
    url: officeData.url,
    token: officeData.token,
    config: officeData.config,
  });
} catch (err) {
  const message =
    err instanceof Error
      ? err.message
      : "Failed to load OnlyOffice configuration";
  setResult(null);
}

b.b. OnlyOffice 编辑器组件

我们定义同前面一样的 OnlyOffice 类型(偷懒也可以直接写 any),然后把这些东西注入到 OnlyOffice API 脚本中。核心部分如下:

const editorRef = useRef<HTMLDivElement>(null);
const docEditorRef = useRef<DocsAPIDocEditor | null>(null);

docEditorRef.current = new window.DocsAPI.DocEditor(
  editorRef.current.id,
  config,
);

这里 DocsAPIDocEditor 就是一个简单的 Editor 接口,也可以传 any。

然后返回这个组件:

return (
  <div className="w-full h-full">
    <div
      id={`onlyoffice-editor-${fileId}`}
      ref={editorRef}
      className="w-full h-full"
    />
  </div>
);

c.c.