odoo本地文档功能开发记录

本文介绍了一种利用OnlyOffice和MinIO优化Odoo文档管理系统的方案。通过接入这两个外部系统,实现了在线预览多种格式文件及高效存储管理,解决了原有系统无法在线预览多种格式文件及文件存储位置频繁变动的问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

应公司要求,需要使用odoo系统作为文档管理,知识库管理,并且经个人分析出以下开发要点:

ps: 我不是产品经理,但是自己开发没产品经理给具体怎么开发的方案,那一切重点都要想清楚再动手

此篇博客长篇大论,本人希望自己以及后来人能明白这个的实现原理, 所以分析需要细致,中间存在的问题也尽量提一下

前提条件,已有基础:

odoo12原生企业版,自带文档功能

一、需求分析

1.公司要求文档功能支持在线预览

分析: 

原生文档功能不支持多种格式在线预览的,只自带支持图片和mp4的在线预览,其他类似文档格式的文件,根本没有办法预览

个人分析,一个文档管理系统,公司使用者大概会上传和想要预览哪些文件?

答案是:office文档,图片,媒体,压缩包

office文档在线预览的话,通常要支持 doc,docx,xls,xlsx,ppt,pdf等格式

图片预览的话一般要支持png,jpg,jped,gif等常规图片格式

媒体预览的话一般要支持mp4,mp3,flv等常见视频音频格式

压缩包在线预览,在线解压缩和内部文件预览的话,抱歉,公司没给我那么多研发时间,直接不考虑,这块儿分析到了,但是我不做

2.公司数据库有经常挪动位置的需求

分析:

odoo系统的文件大部分是以base64格式存在数据库的binary字段里面的,并且原生功能不支持存储位置切换

odoo系统文件只在数据库字段还不行,还记录在data_dir配置参数所在目录,只搬移数据库不搬移data_dir目录的话,数据库里面的文件会丢失

odoo系统数据库和data_dir即使都搬移了,如果此数据库改了一下库名,那么对不起,文件也会丢失

造成的影响:

1.数据库持续存储文件,会越来越大,给数据库备份造成压力,到最后甚至无法备份

2.数据库迁移后,文件基本上都会丢失,影响恶劣

二、解决办法

1.针对原生系统无法在线预览多格式文件的问题,我考虑接入外部预览系统,本地只提取文件访问直链给该系统,即可实现预览

问:为什么不自己开发一个在线预览功能?

答:你开发一个试试?人家是一套专门的预览系统,让你自己去写个原生的office预览,各种算法,没个半年以上即使你是大牛那也得掂量掂量

既然人家有专业的系统了,咱们就不要重复造轮子了好不,即使造出来也没人家的优秀,公司也不可能给你那么多时间去搞这个

问:那需要自己本地搭建这个在线预览系统不?

答:那是肯定的,在线预览系统不是我开发的,但一定要在我本地用,别糊弄我丢个别人的在线预览接口,如果问为什么,那就是文档经过别人的服务器了,那不安全懂不

别人随便动动手就能把你的文件拿走去看看里面有什么

2.针对原生系统文件存储问题,我考虑接入外部存储系统。本地数据库只存文件的详情信息,不存文件的二进制数据

问:怎么又是接入外部存储系统,不自己开发一个

答:以后不要问这种问题了,鄙视重复造轮子懂不(其实是自己技术不够,公司也不给这么多时间啊)

问:接入外部存储系统你是怎么考虑的

答:我考虑过另一个服务器搭建一个 ftp,sftp,或者webdav,甚至去购买七牛云,阿里云,又拍云,鸡毛云的oos对象云储存来对接,但是最后都被我全部打死了

为什么呢?

1. frp文件传输方式老套,个人觉得它跟对象存储比起来,上传下载速度不是一个级别的,我称他龟速没毛病吧

2.webdav,新型的文件传输服务,但是速度嘛,只比ftp好一丢丢,名字厉害而已

3.对接对象云存储,这个好使啊,问题是烧钱。七牛云10g免费,最后实际用起来如果搞个几个TB,存储空间和流量费,一年不得几万?咱是穷人,公司也不愿意花这个成本的

最好的方案是自己出一台服务器,自己搭建个存储系统可以用就行了。勤俭持家的开发人员,处处为公司成本考虑,虽然节省的这费用公司也不会考虑给我加加工资,但是我也愿意这么去做

三、方案落实

思路前面讲的很清楚了,需要两条开发线

1.对接在线文件预览

2.对接外部文件存储

文件预览系统选择:

1. office预览选择 onlyoffice

支持几乎所有office格式及pdf在线预览

2.图片预览选择 viewer.js

支持几乎所有图片预览,可在线放大,旋转等等

3.媒体音视频在线播放选择 dplayer

支持大部分主流视频在线播放,可以倍速

外部文件存储选择:

minio,我叫他米牛,一个开源的类似亚马逊aws3的对象存储系统

四、系统搭建

图片预览和媒体预览,都是人家提供的一个js插件就行了,本地下载下来网页上嵌入使用

具体文档接入流程看上面的链接点进去就行了。这里只详细讲述office预览和文件存储系统的搭建教程

这里都按照docker容器化搭建,docker且安装了portainer面板 详情如下

1. onlyoffice搭建教程

在portainerUI中,创建名为mynet的网络,类型为bridge。网关为 172.20.10.11 ,子网掩码 172.20.0.0/16 ,范围内172.20.10.28/25 。
具体可根据实际情况设置。

在运行docker环境的服务器主机上依次敲命令完成下面镜像的运行:

安装redis redis的服务器地址为172.20.10.9 ,也可以根据容器名访问。 ping 容器名和ip试试。  

docker run --name some-redis --restart=always --net mynet --ip 172.20.10.9 -d redis 

安装rabbitmq 地址为172.20.10.8

docker run --name some-rabbitmq --restart=always --net mynet --ip 172.20.10.8 -d rabbitmq 

 安装postgresql 地址为172.20.10.1

docker run --name some-postgres --restart=always -p 5432:5432 --net mynet --ip 172.20.10.1 -e POSTGRES_PASSWORD=mypassword -d postgres

用navicate软件登陆数据库,数据库类型选postgres,地址是你的这台服务器ip,端口是5432,需要防火墙开放端口5432。然后新建查询执行下面的命令

CREATE DATABASE onlyoffice --创建数据库
CREATE USER onlyoffice WITH password 'onlyoffice' --创建账号
GRANT ALL privileges ON DATABASE onlyoffice TO onlyoffice --设置账号和数据库的关联权限

 安装 onlyoffice 选择合适的镜像 alehoho/oo-ce-docker-license 

docker run --name=onlyoffice --restart=always --detach --publish=8033:80 --net mynet --ip 172.20.10.5 -e LANGUAGE=zh_CN:zh -e JWT_ENABLED=true -e JWT_IN_BODY=true -e JWT_SECRET=secret -e DB_TYPE=postgres -e DB_HOST=172.20.10.1 -e DB_PORT=5432 -e DB_NAME=onlyoffice -e DB_USER=onlyoffice -e DB_PWD=onlyoffice -e AMQP_URI=amqp://guest:guest@172.20.10.8:5672 -e REDIS_SERVER_HOST=172.20.10.9 -e REDIS_SERVER_PORT=6379 alehoho/oo-ce-docker-license

上面这个镜像如果有问题的话,还可以用下面的备用。我自己pull到本地再push到自己仓库的 

docker pull hjdhnx/onlyoffice:210425

docker run --name=onlyoffice --restart=always --detach --publish=8033:80 --net mynet --ip 172.20.10.5 -e LANGUAGE=zh_CN:zh -e JWT_ENABLED=true -e JWT_IN_BODY=true -e JWT_SECRET=secret -e DB_TYPE=postgres -e DB_HOST=172.20.10.1 -e DB_PORT=5432 -e DB_NAME=onlyoffice -e DB_USER=onlyoffice -e DB_PWD=onlyoffice -e AMQP_URI=amqp://guest:guest@172.20.10.8:5672 -e REDIS_SERVER_HOST=172.20.10.9 -e REDIS_SERVER_PORT=6379 hjdhnx/onlyoffice:210425

特别要注意的是:-e JWT_SECRET=secret 这个是秘钥,不要暴露给别人知道。 变量的配置文件在容器中的位置/etc/onlyoffice/documentserver/local.json
这个秘钥的值,要和实例代码配置中的值对应。 如果不设置jwt的验证功能(环境变量 JWT_ENABLED JWT_IN_BODY JWT_SECRET 都不设,且代码settings.config文件中的files.docservice.secret为空)则不进行身份验证

 官方提供的各种语言对接的demo地址

进入后下载python的

会python开发的我想大家都懂django吧,它这个例子很简单,随便看看代码就会了,然后自己开发odoo模块实现类型的功能和接口

最后讲一下必不可少的https搭建访问

将上面运行docker的内置端口映射改一下即可

重点就是你在 docker run镜像images,生成了一个容器container对吧,这里特别注意,平常我们映射容器的80端口出来8033,而现在,你必须映射443端口出来8033哦。

docker run --name=onlyoffice --restart=always --detach --publish=8033:443 --net mynet --ip 172.20.10.5 -e LANGUAGE=zh_CN:zh -e JWT_ENABLED=true -e JWT_IN_BODY=true -e JWT_SECRET=secret -e DB_TYPE=postgres -e DB_HOST=172.20.10.1 -e DB_PORT=5432 -e DB_NAME=onlyoffice -e DB_USER=onlyoffice -e DB_PWD=onlyoffice -e AMQP_URI=amqp://guest:guest@172.20.10.8:5672 -e REDIS_SERVER_HOST=172.20.10.9 -e REDIS_SERVER_PORT=6379 hjdhnx/onlyoffice:210425


下面是推荐docker容器内文件挂载到本地的完整配置,将容器内的 /var/log/onlyoffice 和  /var/www/onlyoffice/Data 目录映射出来

完整代码

cd /home
mkdir office
cd office
mkdir log
mkdir data

docker run --name=onlyoffice --restart=always --detach --publish=8033:443 --net mynet --ip 172.20.10.5 -v /home/office/log:/var/log/onlyoffice -v /home/office/data:/var/www/onlyoffice/Data -e LANGUAGE=zh_CN:zh -e JWT_ENABLED=true -e JWT_IN_BODY=true -e JWT_SECRET=secret -e DB_TYPE=postgres -e DB_HOST=172.20.10.1 -e DB_PORT=5432 -e DB_NAME=onlyoffice -e DB_USER=onlyoffice -e DB_PWD=onlyoffice -e AMQP_URI=amqp://guest:guest@172.20.10.8:5672 -e REDIS_SERVER_HOST=172.20.10.9 -e REDIS_SERVER_PORT=6379 hjdhnx/onlyoffice:210425
这样容器启动后,你在宿主机 /home/office/data目录里面执行操作,创建certs目录
mkdir certs

将自己已有的https证书私钥文件和公钥文件拷贝进certs目录

并且分别改名为  onlyoffice.key 和 onlyoffice.crt,最后重启容器,即可通过 https://域名:8033 访问到onlyoffice的服务了,正常情况访问能看到welcome就是搭建成功了。

请注意,我图中展示的路径和文件位置,是我宿主机上的,把容器内的  /var/www/onlyoffice/Data 映射到的本地宿主机的 /home/office/data

后续可能出现的维护性问题补充:

预览文件提示现在无法打开文件,且去看onlyoffice容器内日志显示

Error: getaddrinfo ENOTFOUND yourdomain.com

解决办法,配置host映射,涉及命令

vi /etc/hosts # 编辑host文件

在里面添加映射 如 ip domain
14.215.177.39 www.baidu.com

然后执行重启网络
network restart

完毕后重启docker服务
service docker restart

最后看看docker容器是否正常,应该就没有问题了

 2.minio搭建教程

这个很简单,一堆docker命令敲就完了,顺着敲。至于什么意思,自己理解每条命令看着改就行了

cd /home
mkdir minio
cd minio
mkdir data
mkdir config
docker run --name minio -v /home/minio/data:/data -v /home/minio/config:/root/.minio -p 8039:9000 --restart=always -e "MINIO_ACCESS_KEY=admin" -e "MINIO_SECRET_KEY=openerphk" minio/minio server /data
Ctr + C
docker start minio

 然后这个东西界面上可以进去访问,类似个网盘,没啥复杂的操作

在这里操作也不是要点,只是表示搭建完了。

这文件预览系统和文件存储系统如果要上公司的生产环境,记得喊运维大哥给这俩地址配置一下ssl证书,能支持https访问才行。

你自己玩的话也可以自己配

正式环境minio系统配置https教程很简单,主要理解官方文档才行:

MinIO | Learn how to secure access to MinIO server with TLS

官方文档推荐的2种方式

1.使用已有的CA证书

如果都是按照上面的搭建教程来的话,请把已经有的证书放在 /home/minio/config/certs 目录,详情见图:

其中 私钥的文件名一定是 private.key ,公钥的文件名一定是 public.crt

根据官方的说法,某些机制颁发的证书,因为生成工具不同,文件名也有所差别

将 cert.pem 重命名为public.crt,将key.pem 重命名为 private.key.

注意,证书位置放好过后直接重启容器就好,不需要自己配置反向代理,不需要配置反向代理,不需要配置反向代理,重要的事情说三遍。

如何访问?

重启容器后,地址栏按原来访问形式,把http改成https即可。

比如:  https://minio.baidu.com:8039/ ,你没有看错,https的端口号自定义了,不是默认的443,请不要惊慌,这就是你docker容器映射到宿主机 的minio服务的端口

其中 minio.baidu.com 是你自己设置的米牛服务器所在的的二级域名指向,改成你自己的。

这里配置成功后,sdk调用米牛云的api,记得把 secure 参数改成true

client = Minio(
        'minio.baidu.com:8039',# mini服务的地址加端口,注意不要在前面写http之类的东西 https://minio.baidu.com/
        access_key="admin",  # 账号
        secret_key="passwd",  # 密码
        secure=True  # 设置非https,默认是https
    )

2.使用openssl生成一个免费的证书

 注意,第一步能解决的就不需要再进行第二步了,第二步是根据没钱买证书的人的

1.生成私钥

openssl genrsa -out private.key 2048

2.编辑保存配置文件

vi openssl.conf

 下面是配置文件内容,改一改粘贴进openssl.conf里面

官方说的是 Create a file named openssl.conf with the content below. Set IP.1 and/or DNS.1 to point to the correct IP/DNS addresses:

[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no

[req_distinguished_name]
C = US
ST = VA
L = Somewhere
O = MyOrg
OU = MyOU
CN = MyServerName

[v3_req]
subjectAltName = @alt_names

[alt_names]
IP.1 = 127.0.0.1
DNS.1 = localhost

3.根据配置生成公钥

openssl req -new -x509 -nodes -days 730 -key private.key -out public.crt -config openssl.conf

 剩下的就跟第一步差不多了,复制文件到  /home/minio/config/certs 再重启容器

好了,https正常访问了,本人亲自动手成功的,你失败的话可不怪我。

五、核心代码对接部分示例

1.minio配置储存桶策略的两种方式,客户端配置或代码配置

客户端配置的话,去这里下载windows客户端

然后cmd要敲的代码,记得自己替换 米牛系统主页地址 为你搭建好的那个minio主页地址:

mc alias set minio 米牛系统主页地址 admin openerphk --api s3v4
mc --json ls play
mc policy
mc policy set download minio/test
mc config host ls

客户端配置策略啥的,作为我这样的开发人员是不需要的,一般代码里动态配置就行了,更简单

贴一段mino工具的python核心代码

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# File  : minioUpload.py
# Author: DaShenHan&道长-----先苦后甜,任凭晚风拂柳颜------
# Date  : 2021-04-19


import logging
from minio import Minio
from minio.commonconfig import REPLACE, CopySource
from minio.error import S3Error
import json

_logger = logging.getLogger(__name__ + '米牛上传')


class minioUploadClass:
    def __init__(self, cfg):
        """
        初始化配置,储存桶不存在则顺带创建桶
        :param cfg:
        """
        global config
        config = cfg
        self.config = cfg
        # print(config.minio_host.rstrip('/'),config.minio_access_key,config.minio_secret_key,config.minio_secure)
        minio_host = config.minio_host.rstrip('/').split('//')[
            1] if '//' in config.minio_host else config.minio_host.rstrip('/')
        self.client = Minio(
            minio_host,  # mini服务的地址加端口,注意不要在前面写http之类的东西,如 dz.mudery.com:8039
            access_key=config.minio_access_key,  # 账号
            secret_key=config.minio_secret_key,  # 密码
            secure=config.minio_secure  # 设置非https,默认是https
        )
        bucket = config.minio_bucket  # 储存桶
        found = self.client.bucket_exists(bucket)
        if not found:
            self.client.make_bucket(bucket, 'cn-north-1')  # 创建中国北部的桶
            self.client.set_bucket_policy(bucket, self.gen_bucket_policy(bucket))
            _logger.info(f'bucket_policy for {bucket} set ok')

    @staticmethod
    def gen_bucket_policy(bucket):
        """
        生成桶策略
        :param bucket:
        :return:
        """
        policy = {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {"AWS": "*"},
                    "Action": [
                        "s3:GetBucketLocation",
                        "s3:ListBucket",
                        "s3:ListBucketMultipartUploads",
                    ],
                    "Resource": f"arn:aws:s3:::{bucket}",
                },
                {
                    "Effect": "Allow",
                    "Principal": {"AWS": "*"},
                    "Action": [
                        "s3:GetObject",
                        "s3:PutObject",
                        "s3:DeleteObject",
                        "s3:ListMultipartUploadParts",
                        "s3:AbortMultipartUpload",
                    ],
                    "Resource": f"arn:aws:s3:::{bucket}/images/*",
                },
            ],
        }
        return json.dumps(policy, ensure_ascii=False)

    def put_object(self,bucket_name,object_name,data,length,content_type='application/octet-stream',metadata=None):
        """
        以二进制字节流形式上传文件
        :param bucket_name: 	存储桶名称。
        :param object_name:   对象名称。
        :param data: 任何实现了io.RawIOBase的python对象。 如 io.BytesIO(bytes)
        :param length: 对象的总长度。 len(bytes)
        :param content_type: 对象的Content type。(可选,默认是“application/octet-stream”)。
        :param metadata: 其它元数据。(可选,默认是None)。
        :return: etag 对象的etag值。
        """
        return self.client.put_object(bucket_name,object_name,data,length,content_type,metadata)

    def copy_object(self,bucket_name,object_name,object_source,copy_conditions=None, metadata=None):
        """
        拷贝对象存储服务上的源对象到一个新对象。
        注意:本API支持的最大文件大小是5GB。
        :param bucket_name: 新对象的存储桶名称。
        :param object_name: 新对象的名称。
        :param object_source: 要拷贝的源对象的存储桶名称+对象名称。/winbao/1.doc
        :param copy_conditions:拷贝操作需要满足的一些条件(可选,默认为None)。
        :param metadata:
        :return:
        """
        return self.client.copy_object(bucket_name,object_name,object_source,copy_conditions,metadata)

    def fput_object(self,*args):
        return self.client.fput_object(*args)

    def stat_object(self,bucket_name, object_name):
        """
        获取对象的元数据。
        :param bucket_name: 	存储桶名称。
        :param object_name:  文件名称
        :return: 对象的统计信息,格式如下:
        obj.size	int	对象的大小。
        obj.etag	string	对象的etag值。
        obj.content_type	string	对象的Content-Type。
        obj.last_modified	time.time	UTC格式的最后修改时间。
        obj.metadata	dict	对象的其它元数据信息。
        """
        return self.client.stat_object(bucket_name, object_name)

    def remove_object(self,bucket_name, object_name):
        """
        删除一个对象。
        :param bucket_name: 存储桶名称。
        :param object_name: 对象名称。
        :return:
        """
        return self.client.remove_object(bucket_name, object_name)

    def move_object(self,bucket_name, object_name,bucket_name_new,object_name_new):
        """
        移动一个对象
        :param bucket_name: 原桶
        :param object_name: 原文件名
        :param bucket_name_new: 新桶
        :param object_name_new: 新文件名
        :return: boolean 是否移动成功
        """
        found = self.client.bucket_exists(bucket_name_new)
        if not found:
            self.client.make_bucket(bucket_name_new, 'cn-north-1')  # 创建中国北部的桶
            self.client.set_bucket_policy(bucket_name_new, self.gen_bucket_policy(bucket_name_new))
            _logger.info(f'bucket_policy for {bucket_name_new} set ok')

        try:
            # object_source = f'/{bucket_name}/{object_name}'
            object_source = CopySource(bucket_name,object_name)
            # print(object_source)
            self.copy_object(bucket_name_new,object_name_new,object_source)
            self.remove_object(bucket_name,object_name)
            return True
        except Exception as e:
            # print(e)
            _logger.error(f'{e}')
            return False

    def remove_objects(self,*args):
        return self.client.remove_objects(*args)

其中关键的初始化参数 cfg是数据库的一条对象记录,也就是odoo文档增强的配置表的记录,仅供参考,配置表minio相关的部分截图参考。

然后我推荐大家做好文件防盗链相关的安全服务,我这里做了一个简单的rsa加密防盗链,接入自己系统的短链接api,实现文件直链的过期时间验证与自定义

2. onlyoffice相关的配置,大概也需要搞个数据库表的字段来这样设置,至于怎么对接,前面说了参考官方python的demo,django代码通俗易懂

都是html代码,没人想看,我这里就不贴代码了,最后补充一下完整的odoo上配置表的界面与成品,有点繁琐,仅作参考

 

 

 

odoo系统这边需要改造原生的文档 上传,下载,分享,替换等功能,左上角增加在线预览按钮

配置增加全局存储方式,附件字段扩展 文件桶,存储方式等字段。就不细说了

最后效果是,在odoo文档功能里面上传文件,如果全局存储方式配置的minio,自动把文件传到预览设置的minio默认桶里面去了

odoo数据库里面只存文件除二进制值以外的信息,数据库不会增大体积,文件在minio那边也不会丢失

在odoo文档里面点击选中文档后点击在线预览按钮,会根据文件类型跳到不同的预览界面,office预览,图片预览,视频在线播放

好了,教程到此结束,只做自己的记录以及其他后来者的参考,伸手党要代码的别来找我,代码是不会给的。谢谢大家观看此博客,我写这篇博客也花了不少时间,但是以后回来看就方便了。

2021-06-21后续补充:

图片存储时进行选择性压缩。压缩图片核心代码如下:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# File  : imgConvertUtils.py
# Author: DaShenHan&道长-----先苦后甜,任凭晚风拂柳颜------
# Date  : 2021-06-21

from PIL import Image
from PIL import ImageFile
import math
import io

ImageFile.LOAD_TRUNCATED_IMAGES = True

class ImageConvert:
    def __init__(self):
        pass

    def convert2rb(self,input_rb,targetWidth,quality=95):
        input_file = io.BytesIO(input_rb)
        sImg = Image.open(input_file)
        ext = sImg.format
        w, h = sImg.size
        rate = round(targetWidth / w, 4)
        height = math.floor(rate * h)
        dImg = sImg.resize((targetWidth, height), Image.ANTIALIAS)
        output = io.BytesIO()
        flag = 'RGBA' if sImg.format.lower() == 'png' else 'RGB'
        dImg.convert(flag).save(output, format=ext, quality=quality)
        return output.getvalue()

if __name__ == '__main__':
    with open(r'D:\Desktop\报销发票附件样例\2.jpg',mode='rb') as f:
        input_rb = f.read()
    img = ImageConvert()
    output_rb = img.convert2rb(input_rb,1080)
    with open(r'D:\Desktop\报销发票附件样例\输出4.jpg','wb+') as f:
        f.write(output_rb)

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值