应公司要求,需要使用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为空)则不进行身份验证
进入后下载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)