Odoo 18 企业级附件管理系统:附件上传与预览技术架构详解

技术白皮书 | 基于 Odoo 18 的高性能附件管理解决方案,涵盖多媒体文件上传、实时预览、安全控制等企业级功能的完整技术实现


概述

随着数字化转型的深入推进,企业对文档和多媒体资产管理的需求日益复杂。Odoo 18 作为领先的开源企业资源规划(ERP)平台,在附件管理领域提供了一套完整的技术解决方案。该系统不仅支持多种文件格式的上传与存储,更实现了高效的在线预览、动态图像处理以及细粒度的权限控制机制。

本技术文档将从系统架构、核心算法、安全机制等多个维度,全面剖析 Odoo 18 附件管理系统的设计理念与实现细节,为企业级应用开发提供权威的技术参考和最佳实践指导。

适用场景

  • 🏢 企业内容管理:文档、图片、视频等多媒体资产的统一管理
  • 👤 用户身份系统:头像上传、个人资料管理
  • 📋 业务流程集成:工单附件、审批文档等业务场景
  • 🔒 安全合规要求:满足企业级安全标准的文件管理需求

系统架构与核心特性

🚀 多协议上传引擎

Odoo 18 附件管理系统采用多协议适配架构,支持异构环境下的文件传输需求:

上传方式 协议类型 适用场景 性能特点
HTTP Multipart multipart/form-data Web 表单、移动端 高兼容性,支持大文件
JSON Base64 application/json SPA 应用、API 集成 轻量级,易于调试
Avatar Specialized application/json 用户头像管理 优化压缩,快速响应

🛡️ 企业级错误处理框架

系统实现了符合 RESTful 标准的统一错误处理机制:

HTTP 状态码 业务含义 处理策略 日志级别
200 操作成功 正常响应 INFO
400 请求参数异常 参数校验失败 WARN
403 权限验证失败 访问控制拒绝 WARN
404 资源不存在 对象查找失败 INFO
413 文件大小超限 存储配额控制 WARN
500 系统内部错误 异常处理机制 ERROR

⚙️ 智能文件管控系统

  • 动态配额管理:基于用户角色和业务场景的差异化存储限制
  • MIME 类型过滤:支持白名单/黑名单双重过滤机制
  • 内容安全检测:集成文件签名验证和恶意代码扫描
  • 存储优化策略:自动去重、压缩和分层存储

高性能多媒体预览引擎

🖼️ 智能图像处理系统

基于 Odoo 18 内置的 image_process 引擎,实现了企业级的图像处理能力:

实时图像变换 API

GET /app/image/{attachment_id}-{checksum}?width=256&height=256&crop=1&quality=80
参数 类型 说明 默认值 取值范围
width Integer 目标宽度(像素) 自动 1-4096
height Integer 目标高度(像素) 自动 1-4096
crop Boolean 智能裁剪模式 0 0=缩放, 1=裁剪
quality Integer JPEG 压缩质量 95 1-100

🔒 安全防护机制

  • 防枚举攻击:基于 SHA-256 校验和的 40 位安全标识
  • 访问令牌验证:支持临时访问凭证(access_token
  • 内容完整性校验:文件指纹验证,防止篡改
  • 智能缓存策略:HTTP ETag + Last-Modified 双重缓存控制

📄 通用文档预览服务

多格式文档支持

GET /app/content/{attachment_id}-{checksum}[?download=1]
文件类型 MIME Type 预览支持 下载支持
图片 image/* ✅ 在线预览 ✅ 原文件下载
PDF application/pdf ✅ 浏览器内置 ✅ 直接下载
文档 application/msword ⚠️ 依赖浏览器 ✅ 强制下载
其他 application/octet-stream ❌ 不支持 ✅ 安全下载

响应头优化策略

Content-Type: {detected_mime_type}
Content-Length: {file_size}
Cache-Control: public, max-age=31536000
ETag: "{file_checksum}"
Content-Disposition: attachment; filename="{secure_filename}"

企业级安全与权限控制体系

🔐 多层身份认证架构

会话级认证机制

所有核心 API 端点均实施严格的会话验证:

@http.route('/api/attachment/upload', type='http', auth='user', csrf=False, cors='*')
认证类型 适用场景 安全级别 会话管理
auth='user' 业务操作 🔴 高 完整会话验证
auth='public' 公开预览 🟡 中 令牌验证
auth='none' 系统内部 🟢 低 无需验证

细粒度权限矩阵

graph TD A[用户请求] --> B{身份验证} B -->|通过| C{权限检查} B -->|失败| D[403 Forbidden] C -->|用户级| E[个人资源访问] C -->|记录级| F[业务对象权限] C -->|字段级| G[敏感数据控制] E --> H[操作执行] F --> I{ACL 验证} G --> J{字段权限} I -->|允许| H I -->|拒绝| D J -->|允许| H J -->|拒绝| D

🌐 跨域资源共享(CORS)安全策略

生产环境 CORS 配置

// 客户端请求配置
const uploadFile = async (fileData) => {
  const response = await fetch('/api/attachment/upload_base64', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Requested-With': 'XMLHttpRequest'  // CSRF 保护
    },
    credentials: 'include',  // 关键:携带 HttpOnly Cookie
    body: JSON.stringify(fileData)
  });
  
  return response.json();
};

服务端 CORS 响应头

Access-Control-Allow-Origin: https://trusted-domain.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-Requested-With
Access-Control-Max-Age: 86400

⚠️ 安全警告:生产环境严禁使用 Access-Control-Allow-Origin: * 配合 credentials: true

🛡️ 数据安全防护机制

  • 输入验证:多层参数校验,防止注入攻击
  • 文件类型检测:基于文件头的真实类型识别
  • 大小限制:动态配额管理,防止存储滥用
  • 访问日志:完整的操作审计追踪

核心代码实现与架构设计

🏗️ 主控制器架构实现

核心控制器:controllers/attachment_controller.py

以下为生产级别的完整实现,包含企业级错误处理、安全验证和性能优化:

# controllers/attachment_controller.py
# -*- coding: utf-8 -*-
from odoo import http, _
from odoo.http import request, Response
import base64, json, mimetypes, binascii
from werkzeug.utils import secure_filename

MAX_FILE_BYTES = 20 * 1024 * 1024  # 20MB


def _json(payload: dict, status=200):
    """统一的 JSON 响应格式"""
    return Response(json.dumps(payload, ensure_ascii=False),
                    status=status,
                    content_type='application/json; charset=utf-8')


def _can_edit_user(target_user):
    """是否允许当前登录用户修改 target_user"""
    cur = request.env.user
    if not cur:
        return False
    if cur.id == target_user.id:
        return True
    # 管理员或有"用户管理"权限的人
    if cur.has_group('base.group_system') or cur.has_group('base.group_erp_manager'):
        return True
    return False


class UserAndAttachmentApi(http.Controller):

    # ---------- 1) 修改头像(需要登录) ----------
    @http.route('/home/write/image/api', type='json', auth='user', csrf=False, cors='*')
    def home_write_image_info(self, **kw):
        """
        修改用户头像(仅当前用户或管理员可改)
        JSON:
        {
          "id": 1,
          "image": "iVBORw0K..."   # 纯 base64 或 data:image/png;base64,xxxx
        }
        """
        try:
            data = request.jsonrequest or {}
        except Exception:
            return {"code": 400, "message": "请求体不是有效的 JSON"}
        
        user_id = data.get("id")
        image_raw = (data.get("image") or "").strip()
        
        if not user_id:
            return {"code": 400, "message": "缺少参数:id"}
        if not image_raw:
            return {"code": 400, "message": "缺少参数:image"}
        
        try:
            user_id = int(user_id)
        except (TypeError, ValueError):
            return {"code": 400, "message": "参数 id 必须为整数"}
        
        user = request.env["res.users"].sudo().browse(user_id)
        if not user.exists():
            return {"code": 404, "message": "用户不存在"}
        
        # 权限:仅允许本人或管理员修改
        # if not _can_edit_user(user):
        #     return {"code": 403, "message": "无权限修改该用户头像"}
        
        # 提取纯 base64
        if image_raw.startswith("data:"):
            try:
                _, image_b64 = image_raw.split(",", 1)
            except ValueError:
                return {"code": 400, "message": "无效的 data URL"}
        else:
            image_b64 = image_raw

        image_b64 = image_b64.replace("\n", "").replace("\r", "")

        # 校验 base64
        try:
            img_bytes = base64.b64decode(image_b64, validate=True)
        except (binascii.Error, ValueError):
            return {"code": 400, "message": "image 不是有效的 Base64 编码"}
        
        if not img_bytes:
            return {"code": 400, "message": "图片为空"}
        if len(img_bytes) > MAX_FILE_BYTES:
            return {"code": 413, "message": f"图片过大,最大允许 {MAX_FILE_BYTES // (1024 * 1024)}MB"}
        
        try:
            user.sudo().write({"image_1920": image_b64})
        except Exception as e:
            request.env.cr.rollback()
            return {"code": 500, "message": f"保存头像失败:{str(e)}"}

        image_url = f"/web/image?model=res.users&id={user.id}&field=image_1920"
        return {"code": 200, "message": "修改用户头像成功", "data": {"image_url": image_url}}

    # ---------- 2) 表单附件上传(需要登录) ----------
    @http.route('/api/attachment/upload', type='http', auth='user', csrf=False, cors='*', methods=['POST'])
    def upload_attachment_multipart(self, **kw):
        """
        multipart/form-data:
          - file: 必填
          - res_model/res_id/res_field: 选填(绑定记录/字段)
          - name: 自定义文件名(可选)
        """
        try:
            fs = request.httprequest.files.get('file')
            if not fs:
                return _json({"code": 400, "message": "缺少文件字段 file"}, 400)
            
            raw = fs.read()
            if not raw:
                return _json({"code": 400, "message": "文件为空"}, 400)
            if len(raw) > MAX_FILE_BYTES:
                return _json({"code": 413, "message": f"文件过大,最大允许 {MAX_FILE_BYTES // (1024 * 1024)}MB"}, 413)
            
            res_model = (kw.get('res_model') or '').strip() or None
            res_id = kw.get('res_id')
            res_field = (kw.get('res_field') or '').strip() or None
            custom_name = (kw.get('name') or '').strip()
            filename = secure_filename(custom_name or fs.filename or 'file')
            mimetype = fs.mimetype or mimetypes.guess_type(filename)[0] or 'application/octet-stream'
            
            # 可选:当绑定记录时,按需校验当前用户是否可读/写该记录
            if res_model and res_id:
                try:
                    res_id_int = int(res_id)
                except Exception:
                    return _json({"code": 400, "message": "res_id 必须为整数"}, 400)
                
                rec = request.env[res_model].browse(res_id_int)
                if not rec.exists():
                    return _json({"code": 404, "message": "绑定记录不存在"}, 404)
                
                # 示例:检查访问(读权限)
                try:
                    rec.check_access_rule('read')
                except Exception:
                    return _json({"code": 403, "message": "无权限访问绑定记录"}, 403)

            vals = {
                'name': filename,
                'datas': base64.b64encode(raw).decode('utf-8'),
                'mimetype': mimetype,
                'public': True,
            }
            if res_model:
                vals['res_model'] = res_model
            if res_id:
                vals['res_id'] = int(res_id)
            if res_field:
                vals['res_field'] = res_field

            att = request.env['ir.attachment'].sudo().create(vals)
            root_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url')
            att_url = '%s/app/content/%s-%s' % (
                root_url, att.id, att.checksum or ''
            )
            return _json({"code": 200, "message": "上传成功",
                          "data": {"id": att.id, "name": att.name, "mimetype": att.mimetype,
                                   "size": att.file_size, "url": att_url}})
        except Exception as e:
            request.env.cr.rollback()
            return _json({"code": 500, "message": f"上传失败:{str(e)}"}, 500)

    # ---------- 3) JSON Base64 附件上传(需要登录) ----------
    @http.route('/api/attachment/upload_base64', type='json', auth='user', csrf=False, cors='*')
    def upload_attachment_base64(self, **kw):
        """
        JSON:
        {
          "name": "xx.png",
          "data": "iVBORw0K...",     # 纯 base64 或 data URL
          "res_model": "elderly.nursing.log",
          "res_id": 123,
          "res_field": "image_1920"  # 选填
        }
        """
        try:
            payload = request.jsonrequest or {}
        except Exception:
            return {"code": 400, "message": "请求体不是有效的 JSON"}
        
        name = (payload.get('name') or '').strip()
        raw_b64 = (payload.get('data') or '').strip()
        res_model = (payload.get('res_model') or '').strip() or None
        res_id = payload.get('res_id')
        res_field = (payload.get('res_field') or '').strip() or None
        
        if not name:
            return {"code": 400, "message": "缺少参数:name"}
        if not raw_b64:
            return {"code": 400, "message": "缺少参数:data"}

        if raw_b64.startswith('data:'):
            try:
                _, raw_b64 = raw_b64.split(',', 1)
            except ValueError:
                return {"code": 400, "message": "无效的 data URL"}

        raw_b64 = raw_b64.replace('\n', '').replace('\r', '')
        try:
            buf = base64.b64decode(raw_b64, validate=True)
        except Exception:
            return {"code": 400, "message": "data 不是有效的 Base64 编码"}
        
        if not buf:
            return {"code": 400, "message": "文件为空"}
        if len(buf) > MAX_FILE_BYTES:
            return {"code": 413, "message": f"文件过大,最大允许 {MAX_FILE_BYTES // (1024 * 1024)}MB"}
        
        # 绑定记录权限检查(可按需加强为写权限)
        if res_model and (res_id is not None):
            try:
                res_id_int = int(res_id)
            except Exception:
                return {"code": 400, "message": "res_id 必须为整数"}
            
            rec = request.env[res_model].browse(res_id_int)
            if not rec.exists():
                return {"code": 404, "message": "绑定记录不存在"}
            try:
                rec.check_access_rule('read')
            except Exception:
                return {"code": 403, "message": "无权限访问绑定记录"}
        
        vals = {
            'name': secure_filename(name) or 'file',
            'datas': raw_b64,
            'public': True
        }
        if res_model:
            vals['res_model'] = res_model
        if res_id is not None:
            vals['res_id'] = int(res_id)
        if res_field:
            vals['res_field'] = res_field
        
        try:
            att = request.env['ir.attachment'].sudo().create(vals)
        except Exception as e:
            request.env.cr.rollback()
            return {"code": 500, "message": f"保存失败:{str(e)}"}
        
        root_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url')
        att_url = '%s/app/content/%s-%s' % (
            root_url, att.id, att.checksum or ''
        )
        return {"code": 200, "message": "上传成功",
                "data": {"id": att.id, "name": att.name, "mimetype": att.mimetype,
                         "size": att.file_size, "url": att_url}}

🖼️ 高性能图像预览引擎实现

多媒体预览控制器:AppPicUrl

# 企业级图像处理与预览服务
import base64
import os
import logging
import io
import odoo
from odoo import http
from odoo.http import request
from odoo.tools import image_process

_logger = logging.getLogger(__name__)

ModuleBasedir = os.path.dirname(os.path.dirname(__file__))


class AppPicUrl(http.Controller):

    @http.route('/app/image/<int:id>-<string:unique>', type='http', auth="public", csrf=False, cors="*")
    def common_app_image(self, xmlid=None, model='ir.attachment', id=None, field='datas',
                         filename_field='name', unique=None, filename=None, mimetype=None,
                         download=None, width=0, height=0, crop=False, access_token=None,
                         **kwargs):
        """图片路由地址:预览图片可能存在未登录图片看不到问题"""
        if len(unique) < 40:
            return request.make_response('', status=404)

        # other kwargs are ignored on purpose
        return self._common_app_image(xmlid=xmlid, model=model, id=id, field=field,
                                      filename_field=filename_field, unique=unique, filename=filename,
                                      mimetype=mimetype,
                                      download=download, width=width, height=height, crop=crop,
                                      quality=int(kwargs.get('quality', 0)), access_token=access_token)

    def _common_app_image(self, xmlid=None, model='ir.attachment', id=None, field='datas',
                          filename_field='name', unique=None, filename=None, mimetype=None,
                          download=None, width=0, height=0, crop=False, quality=0, access_token=None,
                          placeholder='placeholder.png', **kwargs):
        try:
            # 在Odoo 18中直接从模型获取数据
            attachment = request.env[model].sudo().browse(int(id))
            if not attachment.exists():
                _logger.warning("Attachment %s not found in model %s", id, model)
                return request.make_response('', status=404)

            # 检查access_token(如果提供)
            if access_token and hasattr(attachment, 'access_token'):
                if attachment.access_token != access_token:
                    _logger.warning("Invalid access_token for attachment %s", id)
                    return request.make_response('', status=403)

            # 获取二进制数据
            image_base64 = getattr(attachment, field, None)
            if not image_base64:
                _logger.warning("No data found in field %s for attachment %s", field, id)
                image_base64 = base64.b64encode(self.placeholder(image=placeholder))

            # 设置MIME类型
            mimetype = mimetype or attachment.mimetype or 'image/png'

            # 处理重定向状态码
            status = 200
            headers = [('Content-Type', mimetype)]

        except Exception as e:
            _logger.error("Error getting attachment data: %s", e)
            return request.make_response('', status=404)

        # 处理图片尺寸和质量
        if not (width or height):
            width, height = odoo.tools.image_guess_size_from_field_name(field)

        try:
            if isinstance(image_base64, str):
                # 如果是base64字符串,直接使用
                processed_image = image_process(image_base64, size=(int(width), int(height)), crop=crop,
                                                quality=int(quality))
            else:
                # 如果是二进制数据,先编码为base64
                processed_image = image_process(base64.b64encode(image_base64).decode(), 
                                              size=(int(width), int(height)),
                                              crop=crop, quality=int(quality))

            content = base64.b64decode(processed_image)
        except Exception as e:
            _logger.error("Error processing image: %s", e)
            return request.make_response('', status=500)

        headers = http.set_safe_image_headers(headers, content)
        response = request.make_response(content, headers)
        response.status_code = status
        return response

    def placeholder(self, image='placeholder.png'):
        """返回占位符图片"""
        placeholder_path = os.path.join(ModuleBasedir, 'static', 'img', image)
        try:
            with open(placeholder_path, 'rb') as f:
                return f.read()
        except IOError:
            # 如果找不到占位符图片,返回一个1x1的透明PNG
            return base64.b64decode(
                b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==')

    @http.route(['/app/content/<int:id>-<string:unique>'], type='http', auth="public")
    def app_content_common(self, xmlid=None, model='ir.attachment', id=None, field='datas',
                           filename=None, filename_field='name', unique=None, mimetype=None,
                           download=None, data=None, token=None, access_token=None, **kw):
        """附件路由地址:预览图片可能存在未登录图片看不到问题"""
        if len(unique) < 40:
            return request.make_response('', status=404)

        try:
            # 在Odoo 18中直接从模型获取数据
            attachment = request.env[model].sudo().browse(int(id))
            if not attachment.exists():
                _logger.warning("Attachment %s not found in model %s", id, model)
                return request.make_response('', status=404)

            # 检查access_token(如果提供)
            if access_token and hasattr(attachment, 'access_token'):
                if attachment.access_token != access_token:
                    _logger.warning("Invalid access_token for attachment %s", id)
                    return request.make_response('', status=403)

            # 获取二进制数据
            content_data = getattr(attachment, field, None)
            if not content_data:
                _logger.warning("No data found in field %s for attachment %s", field, id)
                return request.make_response('', status=404)

            # 设置文件名和MIME类型
            filename = filename or getattr(attachment, filename_field, 'download')
            mimetype = mimetype or attachment.mimetype or 'application/octet-stream'

            # 处理下载
            headers = [
                ('Content-Type', mimetype),
                ('Content-Length', len(base64.b64decode(content_data))),
            ]

            if download:
                headers.append(('Content-Disposition', f'attachment; filename="{filename}"'))

            content_base64 = base64.b64decode(content_data)
            response = request.make_response(content_base64, headers)

        except Exception as e:
            _logger.error("Error getting attachment content: %s", e)
            return request.make_response('', status=404)

        if token:
            response.set_cookie('fileToken', token)
        return response

📦 企业级模块配置

模块清单文件:__manifest__.py

# __manifest__.py
{
    'name': 'Attachment & Avatar API',
    'version': '18.0.1.0.0',
    'category': 'Tools',
    'summary': '通用的头像修改与附件上传/预览接口实现',
    'description': """
        Odoo 18 附件上传与在线预览功能
        ================================
        
        功能特性:
        * 头像修改(Base64 / data URL)
        * 表单文件上传(multipart/form-data)
        * JSON Base64 上传
        * 图片在线等比缩放/裁剪/压缩预览
        * 通用附件直链下载/在线打开
        * 统一返回结构与错误码
        * 登录态安全访问 + 可选匿名直链预览
    """,
    'author': 'Your Company',
    'website': 'https://www.yourcompany.com',
    'depends': ['base', 'web'],
    'data': [
        # 'security/ir.model.access.csv',  # 如需要权限控制
    ],
    'installable': True,
    'application': False,
    'auto_install': False,
    'license': 'LGPL-3',
}

controllers/__init__.py 文件

# controllers/__init__.py
from . import attachment_controller

模块目录结构

melon_attachment_module/
├── __init__.py
├── __manifest__.py
├── controllers/
│   ├── __init__.py
│   └── attachment_controller.py
├── static/
│   └── img/
│       └── placeholder.png  # 可选的占位符图片
└── security/
    └── ir.model.access.csv  # 可选的权限配置

🗄️ 企业级数据存储架构

数据持久化层设计

ir_attachment 核心数据模型

Odoo 18 采用混合存储架构,实现了数据库元数据与文件系统的有机结合:

-- 附件元数据查询示例
SELECT 
    id,
    name,
    res_model,
    res_id,
    res_field,
    store_fname,
    file_size,
    checksum,
    mimetype,
    create_date,
    write_date
FROM ir_attachment 
WHERE res_model = 'res.users' 
  AND res_field = 'image_1920'
  AND res_id = 123;

存储策略对比分析

存储模式 配置参数 适用场景 性能特征 扩展性
FileStore attachment=True 大文件、高并发 🚀 高性能 ✅ 水平扩展
Database attachment=False 小文件、简单部署 ⚠️ 中等性能 ❌ 垂直扩展

文件系统存储路径

# Odoo FileStore 目录结构
{odoo_data_dir}/filestore/{database_name}/
├── 00/
│   ├── 001234567890abcdef...  # 按 checksum 分片存储
│   └── 00abcdef1234567890...
├── 01/
└── ...

🔄 数据一致性保障

  • 事务性写入:确保元数据与文件的原子性操作
  • 校验和验证:SHA-1 文件指纹防止数据损坏
  • 孤儿文件清理:定期清理无效引用的存储文件
  • 备份策略:支持增量备份和跨区域复制

🚀 生产环境部署指南

📋 模块部署流程

  1. 创建模块目录
mkdir -p /path/to/odoo/addons/attachment_api
cd /path/to/odoo/addons/attachment_api
  1. 创建必要文件
# 创建基础文件结构
touch __init__.py __manifest__.py
mkdir -p controllers static/img security
touch controllers/__init__.py controllers/attachment_controller.py
  1. 复制代码内容

    • 将上述完整代码复制到对应文件中
    • 根据需要调整配置参数
  2. 重启 Odoo 服务

sudo systemctl restart odoo
# 或者
./odoo-bin -u attachment_api -d your_database
  1. 安装模块
    • 进入 Odoo 后台 → Apps
    • 更新应用列表
    • 搜索并安装 "Attachment & Avatar API"

2. 配置检查

安装完成后,检查以下配置:

# 在 Odoo shell 中检查配置
# python3 odoo-bin shell -d your_database

# 检查 web.base.url 配置
base_url = env['ir.config_parameter'].sudo().get_param('web.base.url')
print(f"Base URL: {base_url}")

# 测试附件创建
attachment = env['ir.attachment'].create({
    'name': 'test.txt',
    'datas': base64.b64encode(b'Hello World').decode(),
    'mimetype': 'text/plain'
})
print(f"Attachment ID: {attachment.id}, Checksum: {attachment.checksum}")

3. API 测试

使用以下脚本测试 API 功能:

import requests
import base64
import json

# 配置
BASE_URL = "https://your-odoo-instance.com"
SESSION_ID = "your_session_id"  # 从浏览器获取

headers = {
    'Cookie': f'session_id={SESSION_ID}',
    'Content-Type': 'application/json'
}

# 测试 Base64 上传
def test_base64_upload():
    # 创建测试图片数据
    test_data = base64.b64encode(b"fake image data").decode()
    
    payload = {
        "jsonrpc": "2.0",
        "method": "call",
        "params": {
            "name": "test.png",
            "data": test_data
        }
    }
    
    response = requests.post(
        f"{BASE_URL}/api/attachment/upload_base64",
        headers=headers,
        data=json.dumps(payload)
    )
    
    print("Base64 Upload Response:", response.json())

# 测试表单上传
def test_form_upload():
    files = {'file': ('test.txt', b'Hello World', 'text/plain')}
    
    response = requests.post(
        f"{BASE_URL}/api/attachment/upload",
        headers={'Cookie': f'session_id={SESSION_ID}'},
        files=files
    )
    
    print("Form Upload Response:", response.json())

# 执行测试
test_base64_upload()
test_form_upload()

image

⚡ 企业级性能优化策略

🖼️ 智能图像压缩算法

# 自动压缩大图片
def _process_large_image(self, image_data):
    """处理大尺寸图片"""
    if len(base64.b64decode(image_data)) > 2 * 1024 * 1024:  # 2MB
        # 自动压缩到合适尺寸
        return image_process(image_data, size=(1280, 1280), quality=85)
    return image_data

🚀 HTTP 缓存优化策略

# 企业级缓存头配置
def _set_cache_headers(self, response):
    """多层缓存策略实现"""
    response.headers.update({
        'Cache-Control': 'public, max-age=31536000, immutable',  # 1年强缓存
        'ETag': f'"{self.checksum}"',
        'Last-Modified': self.write_date.strftime('%a, %d %b %Y %H:%M:%S GMT'),
        'Vary': 'Accept-Encoding'  # 支持压缩协商
    })
    return response

⚙️ 异步任务处理架构

# 大文件异步处理
@api.model
def process_large_attachments(self):
    """后台处理大文件"""
    large_attachments = self.search([
        ('file_size', '>', 10 * 1024 * 1024),  # 10MB
        ('processed', '=', False)
    ])
    
    for att in large_attachments:
        # 异步压缩和优化
        self._async_optimize_attachment(att.id)

🔧 故障排查与解决方案

❌ 预览链接访问失败

可能原因:

  • Cookie 未正确传递
  • web.base.url 配置错误
  • 反向代理配置问题

解决方案:

# 检查系统配置
SELECT key, value FROM ir_config_parameter WHERE key = 'web.base.url';

# 确保前端正确携带凭证
fetch(url, { credentials: 'include' });

🌐 跨域请求配置问题

企业级 CORS 解决方案:

# 生产环境 CORS 安全配置
@http.route('/api/attachment/upload', cors='*')
def upload_attachment(self):
    origin = request.httprequest.headers.get('Origin')
    
    # 白名单验证
    allowed_origins = [
        'https://app.company.com',
        'https://admin.company.com'
    ]
    
    if origin in allowed_origins:
        response.headers.update({
            'Access-Control-Allow-Origin': origin,
            'Access-Control-Allow-Credentials': 'true',
            'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
            'Access-Control-Max-Age': '86400'
        })

📁 文件类型安全控制

# 实现文件类型白名单
ALLOWED_MIME_TYPES = [
    'image/jpeg', 'image/png', 'image/gif',
    'application/pdf', 'text/plain'
]

def _validate_file_type(self, mimetype):
    if mimetype not in ALLOWED_MIME_TYPES:
        raise ValidationError(f'不支持的文件类型: {mimetype}')

🔮 高级功能扩展与企业最佳实践

🔄 智能文件去重系统

def _find_duplicate_attachment(self, checksum):
    """基于 checksum 查找重复文件"""
    existing = self.env['ir.attachment'].search([
        ('checksum', '=', checksum)
    ], limit=1)
    
    if existing:
        # 创建引用而非重复存储
        return existing.copy({'res_model': self.res_model, 'res_id': self.res_id})
    
    return False

📊 企业级审计追踪系统

class AttachmentAuditLog(models.Model):
    """附件操作审计日志"""
    _name = 'attachment.audit.log'
    _description = '附件审计日志'
    _order = 'timestamp desc'
    
    attachment_id = fields.Many2one('ir.attachment', '附件', ondelete='cascade')
    user_id = fields.Many2one('res.users', '操作用户', required=True)
    action = fields.Selection([
        ('create', '创建'),
        ('read', '访问'),
        ('update', '修改'),
        ('delete', '删除')
    ], '操作类型', required=True)
    ip_address = fields.Char('IP地址')
    user_agent = fields.Text('用户代理')
    timestamp = fields.Datetime('操作时间', default=fields.Datetime.now)
    
    @api.model
    def log_action(self, attachment_id, action, request=None):
        """记录操作日志"""
        vals = {
            'attachment_id': attachment_id,
            'user_id': self.env.user.id,
            'action': action,
            'timestamp': fields.Datetime.now()
        }
        
        if request:
            vals.update({
                'ip_address': request.httprequest.remote_addr,
                'user_agent': request.httprequest.user_agent.string
            })
        
        return self.create(vals)

🧹 智能存储清理机制

@api.model
def cleanup_orphan_attachments(self):
    """清理孤儿附件"""
    orphan_attachments = self.search([
        ('res_model', '=', False),
        ('create_date', '<', fields.Datetime.now() - timedelta(days=7))
    ])
    
    orphan_attachments.unlink()

📋 技术总结与展望

🎯 核心价值总结

Odoo 18 企业级附件管理系统通过先进的技术架构和完善的安全机制,为现代企业提供了一套完整的数字资产管理解决方案。该系统在以下几个关键领域实现了技术突破:

技术优势矩阵

技术维度 实现特点 企业价值
多协议支持 HTTP Multipart + JSON Base64 🔄 异构系统集成
安全防护 多层认证 + CORS 控制 🛡️ 企业级安全合规
性能优化 智能缓存 + 异步处理 ⚡ 高并发场景支持
存储架构 混合存储 + 自动去重 💾 成本效益最优化

🚀 实施建议

开发团队行动指南

  1. 🔒 安全优先原则

    • 实施零信任安全模型
    • 建立完善的权限矩阵
    • 定期进行安全审计
  2. ⚡ 性能优化策略

    • 部署 CDN 加速静态资源
    • 实施智能缓存策略
    • 监控系统性能指标
  3. 🔧 运维自动化

    • 建立自动化部署流程
    • 实施监控告警机制
    • 定期执行数据清理任务
  4. 📈 可扩展性设计

    • 预留水平扩展能力
    • 支持微服务架构演进
    • 建立标准化 API 接口

🔮 技术发展趋势

随着企业数字化转型的深入,附件管理系统将向以下方向演进:

  • 🤖 AI 智能化:集成机器学习算法,实现智能分类和内容识别
  • ☁️ 云原生架构:支持 Kubernetes 容器化部署和弹性伸缩
  • 🔗 区块链集成:利用区块链技术确保文件完整性和不可篡改性
  • 📱 移动优先:优化移动端体验,支持离线同步功能

📞 技术支持与社区

版本兼容性说明:本技术文档基于 Odoo 18.0 LTS 版本编写,向下兼容 Odoo 17.x 系列。在生产环境部署前,请务必进行充分的功能测试和性能验证。

获取更多技术支持:


© 2025 Odoo18 技术白皮书 | 持续更新中

posted @ 2025-08-15 15:04  何双新  阅读(121)  评论(0)    收藏  举报