Odoo18滑块验证码系统:从设计到实现的完整技术解析

Odoo18滑块验证码系统:从设计到实现的完整技术解析

目录

  1. 前言
  2. 系统架构设计
  3. 数据模型设计
  4. 核心算法实现
  5. 控制器实现
  6. 短信服务集成
  7. 前端交互实现
  8. 安全性考虑
  9. 性能优化
  10. 部署与配置
  11. 测试与验证
  12. 总结与展望

前言

在现代Web应用安全防护体系中,验证码作为人机识别的第一道防线,承担着抵御恶意攻击和垃圾信息的重要使命。传统的字符识别验证码虽然安全性较高,但用户体验欠佳,经常因为字符模糊难认而导致用户流失。而滑块验证码以其直观的交互方式、较低的操作门槛和良好的视觉体验,已成为现代Web应用的首选验证方案。

本文将深入剖析在Odoo18企业级应用框架中构建完整滑块验证码系统的技术实现,涵盖从底层数学算法、图像处理技术、前端交互设计到短信集成服务的全链路技术方案。通过本文的学习,读者将掌握一套工业级验证码系统的核心技术和最佳实践。

系统架构设计

整体架构

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   前端页面      │    │   Odoo控制器    │    │   数据模型      │
│                 │    │                 │    │                 │
│ - 滑块交互      │◄──►│ - 验证码生成    │◄──►│ - 会话存储      │
│ - 拖拽验证      │    │ - 位置验证      │    │ - 图片存储      │
│ - 结果反馈      │    │ - 短信发送      │    │ - 状态管理      │
└─────────────────┘    └─────────────────┘    └─────────────────┘
                                │
                                ▼
                       ┌─────────────────┐
                       │   阿里云短信      │
                       │                 │
                       │ - 验证码发送      │
                       │ - 模板管理        │
                       └─────────────────┘

核心组件

  1. CaptchaSession模型 - 负责验证码会话的数据持久化,包括图像存储、位置信息和状态管理
  2. CaptchaController控制器 - 实现验证码的生成与校验逻辑,处理HTTP请求响应
  3. 图像处理算法 - 基于贝塞尔曲线的拼图形状生成和动态背景渲染
  4. 阿里云短信服务 - 集成第三方SMS服务,实现验证码的可靠投递
  5. 前端交互界面 - 提供响应式的拖拽操作体验,支持多设备兼容

数据模型设计

CaptchaSession模型

class CaptchaSession(models.Model):
    _name = 'captcha.session'
    _description = '滑块验证码会话'
    _rec_name = 'session_id'

    session_id = fields.Char(string="Session ID", required=True, index=True)
    bg_image = fields.Binary(string="背景图", attachment=True)
    slider_image = fields.Binary(string="滑块图", attachment=True)
    puzzle_x = fields.Integer(string="拼图 X 坐标", required=True)
    puzzle_y = fields.Integer(string="拼图 Y 坐标", required=True)
    puzzle_size = fields.Integer(string="拼图尺寸", required=True)
    is_verified = fields.Boolean(string="是否已验证", default=False)

设计要点:

  • session_id:采用UUID4算法生成128位唯一标识符,确保会话ID在分布式环境下的全局唯一性
  • bg_image/slider_image:利用Odoo的Binary字段结合attachment机制,实现图像的高效存储和访问优化
  • puzzle_x/y/size:精确记录拼图块的空间坐标和几何尺寸,为后续位置校验提供标准参照
  • is_verified:采用布尔标志位实现验证状态管理,有效防止验证码的重放攻击

图片处理工具方法

@api.model
def create_from_pillow(self, bg_img, slider_img, puzzle_x, puzzle_y, puzzle_size):
    """ 从 Pillow Image 对象创建记录 """
    session_id = str(uuid.uuid4())
    return self.create({
        'session_id': session_id,
        'bg_image': base64.b64encode(self.image_to_bytes(bg_img)).decode(),
        'slider_image': base64.b64encode(self.image_to_bytes(slider_img)).decode(),
        'puzzle_x': puzzle_x,
        'puzzle_y': puzzle_y,
        'puzzle_size': puzzle_size
    })

@staticmethod
def image_to_bytes(image):
    buf = BytesIO()
    image.save(buf, format='PNG')
    return buf.getvalue()

核心算法实现

1. 贝塞尔曲线拼图形状生成

三次贝塞尔曲线算法

def _cubic_bezier(p0, p1, p2, p3, steps=40):
    """采样三次贝塞尔曲线,返回点序列"""
    pts = []
    for i in range(steps + 1):
        t = i / steps
        mt = 1 - t
        x = (mt ** 3) * p0[0] + 3 * (mt ** 2) * t * p1[0] + 3 * mt * (t ** 2) * p2[0] + (t ** 3) * p3[0]
        y = (mt ** 3) * p0[1] + 3 * (mt ** 2) * t * p1[1] + 3 * mt * (t ** 2) * p2[1] + (t ** 3) * p3[1]
        pts.append((x, y))
    return pts

算法特点:

  • 基于三次贝塞尔曲线的参数方程,通过四个控制点精确控制曲线形态,生成高度平滑的拼图边缘
  • 利用控制点的非线性调节特性,打破传统几何图形的规律性,有效提升图像识别的难度
  • 支持自适应采样密度调节,在保证曲线连续性的前提下,实现计算性能与视觉精度的最优平衡

圆角矩形轮廓生成

def _rounded_rect_points(w, h, r, steps=18):
    """用近似圆弧(多段线)生成圆角矩形轮廓点"""
    r = max(0, min(r, min(w, h) / 2))
    pts = []
    
    def arc(cx, cy, start_deg, end_deg):
        for i in range(steps + 1):
            t = i / steps
            ang = math.radians(start_deg + (end_deg - start_deg) * t)
            pts.append((cx + r * math.cos(ang), cy + r * math.sin(ang)))
    
    # 生成四个角的圆弧
    arc(r, r, 180, 270)  # 左上角
    pts.append((w - r, 0))  # 上边直线
    arc(w - r, r, 270, 360)  # 右上角
    # ... 其他角的处理
    return pts

动态凸起/凹陷生成

def _bezier_lobe_on_side(w, h, side, depth, width_ratio=0.46, steps=36, inward=False):
    """
    在指定边生成"鼻子"形状:由两段贝塞尔拼接
    side: 0=上, 1=右, 2=下, 3=左
    depth: 鼻子伸出(>0)或凹入(<0)的像素
    width_ratio: 鼻子在该边的宽度比例(0~1)
    inward: True=凹口,False=凸起
    """
    if side in (0, 2):  # 顶/底边
        span = w * width_ratio
        x0 = (w - span) / 2
        x3 = x0 + span
        y = 0 if side == 0 else h
        dy = -abs(depth) if (side == 0) ^ inward else abs(depth)
        
        # 生成两段贝塞尔曲线
        xm = (x0 + x3) / 2
        c1 = (x0 + (xm - x0) * 0.6, y)
        c2 = (xm - span * 0.12, y + dy)
        c3 = (xm + span * 0.12, y + dy)
        c4 = (x3 - (x3 - xm) * 0.6, y)
        
        seg1 = _cubic_bezier((x0, y), c1, c2, (xm, y + dy), steps // 2)
        seg2 = _cubic_bezier((xm, y + dy), c3, c4, (x3, y), steps // 2)
        return seg1 + seg2[1:]

设计亮点:

  • 采用随机化策略动态选择凸起或凹陷形态,显著增加机器学习模型的识别难度
  • 通过双段贝塞尔曲线的精密拼接,确保形状过渡的自然性和视觉连贯性
  • 实现全方位的特征生成能力,支持上下左右四个边界的特征点生成,提供丰富的形状变化

2. 拼图蒙版生成与抗锯齿

def _puzzle_mask_bezier(size, knob_side=None, inward=None, seed=None):
    """生成贝塞尔拼图蒙版(L 模式),带抗锯齿"""
    if seed is not None:
        random.seed(seed)
    
    scale = 3  # 超采样倍率
    W = H = size * scale
    
    # 1. 创建高分辨率蒙版
    m = Image.new('L', (W, H), 0)
    d = ImageDraw.Draw(m)
    
    # 2. 绘制圆角矩形主体
    margin = int(W * 0.06)
    body_w = W - margin * 2
    body_h = H - margin * 2
    rx = ry = int(min(body_w, body_h) * 0.18)
    body_pts = _rounded_rect_points(body_w, body_h, rx, steps=40)
    body_pts = [(x + margin, y + margin) for (x, y) in body_pts]
    d.polygon(body_pts, fill=255)
    
    # 3. 添加随机凸起/凹陷
    side = random.randint(0, 3) if knob_side is None else int(knob_side)
    is_inward = random.choice([True, False]) if inward is None else bool(inward)
    depth = int(min(body_w, body_h) * (0.22 + random.uniform(-0.04, 0.04)))
    
    lobe = _bezier_lobe_on_side(body_w, body_h, side, depth=depth,
                                width_ratio=random.uniform(0.40, 0.55),
                                steps=46, inward=is_inward)
    
    # 4. 合成最终蒙版
    lobe_mask = Image.new('L', (W, H), 0)
    ImageDraw.Draw(lobe_mask).polygon(lobe, fill=255)
    
    if is_inward:
        m = ImageChops.subtract(m, lobe_mask)  # 凹陷
    else:
        m = ImageChops.lighter(m, lobe_mask)   # 凸起
    
    # 5. 抗锯齿处理
    m = m.filter(ImageFilter.GaussianBlur(0.6))
    m = m.resize((size, size), Image.LANCZOS)
    return m

抗锯齿技术详解:

  • 超采样技术:采用3倍分辨率进行初始渲染,通过空间过采样有效消除锯齿现象,提升边缘平滑度
  • 高斯模糊滤波:应用σ=0.6的高斯核进行边缘软化处理,实现像素级的平滑过渡效果
  • LANCZOS重采样:采用Lanczos插值算法进行图像缩放,最大限度保持图像细节和锐度

3. 动态背景生成

def _gen_bg(width, height):
    """生成随机渐变+噪声背景"""
    # 1. 纵向线性渐变
    top = tuple(random.randint(120, 200) for _ in range(3))
    bottom = tuple(random.randint(160, 230) for _ in range(3))
    bg = Image.new('RGB', (width, height))
    draw = ImageDraw.Draw(bg)
    
    for y in range(height):
        r = y / max(1, height - 1)
        color = tuple(int(top[i] * (1 - r) + bottom[i] * r) for i in range(3))
        draw.line([(0, y), (width, y)], fill=color)
    
    # 2. 随机圆点纹理
    for _ in range(50):
        r = random.randint(6, 18)
        x = random.randint(-10, width + 10)
        y = random.randint(-10, height + 10)
        c = tuple(random.randint(180, 240) for _ in range(3))
        draw.ellipse([x - r, y - r, x + r, y + r], outline=c, width=1)
    
    # 3. 噪声混合
    noise = Image.effect_noise((width, height), random.uniform(20, 40)).convert('L')
    noise = ImageOps.autocontrast(noise, cutoff=2)
    bg = Image.blend(bg, Image.merge('RGB', (noise, noise, noise)), alpha=0.08)
    bg = bg.filter(ImageFilter.GaussianBlur(0.5))
    return bg

背景设计特点:

  • 线性渐变技术:基于RGB色彩空间的纵向渐变算法,创造视觉层次感,避免单调背景对比度过低
  • 程序化纹理生成:通过随机分布的几何图元叠加,构建复杂的视觉干扰模式,提升识别算法的破解难度
  • 自适应噪声注入:采用控制性噪声混合技术,在保持视觉美观的前提下,有效对抗基于模板匹配的自动化攻击
  • 全局软化处理:通过轻量级高斯滤波实现整体视觉柔化,提升用户的视觉舒适度

控制器实现

验证码生成接口

@http.route('/web/captcha/generate1', type='json', auth='public', csrf=False, cors='*')
def generate_captcha1(self, **kw):
    width, height, puzzle_size = 300, 200, 44
    
    # 1. 生成背景图
    bg_image = _gen_bg(width, height)
    
    # 2. 随机拼图位置
    margin = 12 + puzzle_size // 4
    puzzle_x = random.randint(margin, width - puzzle_size - margin)
    puzzle_y = random.randint(margin, height - puzzle_size - margin)
    
    # 3. 生成拼图蒙版
    mask = _puzzle_mask_bezier(puzzle_size)
    
    # 4. 抠出滑块
    crop_box = (puzzle_x, puzzle_y, puzzle_x + puzzle_size, puzzle_y + puzzle_size)
    piece_rgb = bg_image.crop(crop_box).convert('RGBA')
    piece_rgb.putalpha(mask)
    
    # 5. 添加阴影效果
    shadow = Image.new('RGBA', (puzzle_size, puzzle_size), (0, 0, 0, 0))
    sdraw = ImageDraw.Draw(shadow)
    sdraw.bitmap((0, 0), mask, fill=(0, 0, 0, 80))
    shadow = shadow.filter(ImageFilter.GaussianBlur(2))
    slider_piece = Image.alpha_composite(shadow, piece_rgb)
    
    # 6. 在背景上创建缺口
    cutout = Image.new('RGBA', (puzzle_size, puzzle_size), (255, 255, 255, 255))
    bg_image = bg_image.convert('RGBA')
    bg_image.paste(cutout, (puzzle_x, puzzle_y), mask)
    
    # 7. 添加边缘高光
    edge = Image.new('L', (puzzle_size, puzzle_size), 0)
    ed = ImageDraw.Draw(edge)
    ed.bitmap((0, 0), mask, fill=255)
    edge = edge.filter(ImageFilter.FIND_EDGES).filter(ImageFilter.GaussianBlur(0.8))
    edge_rgba = Image.new('RGBA', (puzzle_size, puzzle_size), (255, 255, 255, 140))
    edge_rgba.putalpha(edge)
    bg_image.paste(edge_rgba, (puzzle_x, puzzle_y), edge_rgba)
    
    # 8. 保存到数据库
    captcha = request.env['captcha.session'].sudo().create_from_pillow(
        bg_image.convert('RGB'), slider_piece, puzzle_x, puzzle_y, puzzle_size
    )
    
    # 9. 生成访问URL
    base = request.env['ir.config_parameter'].sudo().get_param('web.base.url').rstrip('/')
    # ... URL生成逻辑
    
    return {
        "code": 0,
        "msg": "ok",
        "data": {
            "captcha_id": captcha.session_id,
            "bg_url": bg_url,
            "slider_url": slider_url,
            "puzzle_size": puzzle_size,
            "puzzle_y": captcha.puzzle_y,
            "puzzle_x": captcha.puzzle_x,  # 生产环境应移除
            "ttl": CAPTCHA_TTL_MIN * 60
        }
    }

验证码校验接口

@http.route('/web/captcha/verify1', type='json', auth='public', csrf=False, cors='*')
def verify_captcha1(self, **kw):
    """验证通过并发送手机验证码"""
    data = request.jsonrequest
    offset_x = data.get('offset_x')
    phone = str(data['phone'])
    captcha_id = data.get('captcha_id')
    
    # 1. 查找验证码会话
    rec = request.env['captcha.session'].sudo().search([
        ('session_id', '=', captcha_id)
    ], limit=1)
    
    if not rec:
        return {"code": 500, 'message': '验证码不存在'}
    
    # 2. 位置验证(容错5像素)
    if abs(rec.puzzle_x - int(offset_x)) <= 5:
        rec.is_verified = True
        
        # 3. 生成短信验证码
        business_id = uuid.uuid1()
        user_obj = request.env['res.users'].sudo()
        
        if len(phone) == 11:
            # 生成4位随机数字验证码
            verify_code = ''.join([str(random.randint(1, 9)) for _ in range(4)])
            
            # 4. 查找用户
            users = user_obj.search([('login', '=', phone)], limit=1)
            if not users:
                return {"code": 500, "message": "系统中用户不存在"}
            
            # 5. 更新用户验证码
            users.write({
                'dynamic_password': verify_code,
                'dynamic_password_datetime': datetime.datetime.now()
            })
            
            # 6. 发送短信
            params = f"{{'code':{verify_code}}}"
            result = AliFMSms.send_sms(
                business_id, phone, Signature_name, 'SMS_235985354', params
            )
            
            result_data = eval(result)
            if result_data.get('Code') == 'OK':
                return {"code": 200, "message": "SUCCESS", "verifycode": verify_code}
            else:
                return {"code": 500, "message": result}
        else:
            return {"code": 500, "message": "手机号码格式有误"}
    else:
        return {"code": 500, 'message': '验证失败'}

验证逻辑要点:

  • 智能容错机制:设置5像素的位置容错阈值,平衡安全性与易用性,避免因微小偏差导致的误判
  • 状态机管理:基于原子性操作的会话状态管理,确保验证流程的幂等性和安全性
  • 用户身份校验:实施严格的用户存在性验证,确保短信验证码仅发送给系统注册用户
  • 验证码优化设计:采用4位纯数字方案,避免易混淆字符(如0/O、1/l),提升输入准确率
  • 第三方服务集成:深度集成阿里云SMS服务,实现高可用的验证码投递和状态追踪

短信服务集成

阿里云SMS配置

# 阿里云短信配置
REGION = "cn-hangzhou"
PRODUCT_NAME = "Dysmsapi"
DOMAIN = "dysmsapi.aliyuncs.com"
ACCESSKEY = 'your_access_key'
ACCESS_SECRET = 'your_access_secret'

# 短信模板配置
SMS_template = {
    'login': 'SMS_235985354'  # 登录验证码模板
}
Signature_name = '智加科技'  # 短信签名

# 安全配置
TOLERANCE_PX = 6          # 横向容错像素
CAPTCHA_TTL_MIN = 10      # 滑块有效期(分钟)
OTP_TTL_MIN = 5           # 短信码有效期(分钟)
OTP_COOLDOWN_SEC = 60     # 发送冷却时间(秒)
OTP_MAX_PER_HOUR = 5      # 每小时最大发送次数
OTP_MAX_PER_DAY = 12      # 每日最大发送次数

SMS发送服务

class AliFMSms:
    @staticmethod
    def send_sms(business_id, phone_numbers, sign_name, template_code, template_param=None):
        # 创建阿里云客户端
        acs_client = AcsClient(ACCESSKEY, ACCESS_SECRET, REGION)
        region_provider.add_endpoint(PRODUCT_NAME, REGION, DOMAIN)
        
        # 构建短信请求
        sms_request = SendSmsRequest.SendSmsRequest()
        sms_request.set_TemplateCode(template_code)
        sms_request.set_OutId(business_id)
        sms_request.set_SignName(sign_name)
        sms_request.set_PhoneNumbers(phone_numbers)
        
        if template_param:
            sms_request.set_TemplateParam(template_param)
        
        # 发送短信
        sms_response = acs_client.do_action_with_exception(sms_request)
        return sms_response

安全特性:

  • 多维度频率控制:实施时间窗口、小时级别、日级别的多层次频率限制,有效防范短信轰炸攻击
  • 模板化内容管控:强制使用预审核的短信模板,从源头杜绝恶意内容和垃圾信息的传播
  • 全链路追踪机制:基于业务流水号的端到端追踪体系,支持完整的审计和问题定位
  • 异常处理与降级:构建完善的异常捕获和降级策略,确保服务的高可用性和故障恢复能力

前端交互实现

HTML结构设计

<div class="stage" id="stage">
  <img id="bg" class="bg" alt="背景图" />
  <img id="piece" class="slider-piece" alt="滑块" draggable="false" />
</div>

<div class="track">
  <div class="handle" id="handle">↔</div>
  <div id="hint">按住圆钮拖动至正确位置</div>
</div>

设计特点:

  • 分层架构设计:采用背景图层与滑块图层的分离式结构,实现高效的渲染性能和灵活的动画控制
  • 专用交互轨道:提供独立的拖拽操作区域,确保操作精度和用户体验的一致性
  • 实时状态反馈:构建即时响应的视觉反馈机制,为用户提供清晰的操作指导和结果提示

核心JavaScript逻辑

拖拽事件处理

// 拖拽状态管理
let dragging = false;
let startX = 0;
let handleStartLeft = 0;
let currentOffsetX = 0;

function onDragStart(clientX) {
    dragging = true;
    startX = clientX;
    const m = /translateX\(([-\d.]+)px\)/.exec(handle.style.transform || '');
    handleStartLeft = m ? parseFloat(m[1]) : 0;
    document.body.style.userSelect = 'none';
}

function onDragMove(clientX) {
    if (!dragging) return;
    const dx = clientX - startX;
    const maxX = stage.clientWidth - captcha.puzzleSize;
    let nx = Math.min(Math.max(handleStartLeft + dx, 0), maxX);
    
    // 更新句柄位置
    handle.style.transform = `translateX(${nx}px)`;
    
    // 同步滑块位置
    currentOffsetX = Math.round(nx);
    piece.style.transform = `translate(${currentOffsetX}px, ${captcha.puzzleY}px)`;
}

function onDragEnd() {
    dragging = false;
    document.body.style.userSelect = '';
}

多设备兼容

// 鼠标事件
handle.addEventListener('mousedown', (e) => onDragStart(e.clientX));
window.addEventListener('mousemove', (e) => onDragMove(e.clientX));
window.addEventListener('mouseup', onDragEnd);

// 触摸事件
handle.addEventListener('touchstart', (e) => {
    const t = e.touches[0];
    onDragStart(t.clientX);
}, {passive: true});

window.addEventListener('touchmove', (e) => {
    if (!dragging) return;
    const t = e.touches[0];
    onDragMove(t.clientX);
}, {passive: true});

window.addEventListener('touchend', onDragEnd);

API通信

function api(url, payload) {
    return fetch(url, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(payload || {})
    }).then(async r => {
        const data = await r.json().catch(() => ({}));
        return data;
    });
}

// 验证码生成
async function generate() {
    const res = await api(base + '/web/captcha/generate1', {});
    const payload = unwrap(res);
    
    if (payload.code === 0 && payload.data) {
        const d = payload.data;
        captcha.id = d.captcha_id;
        captcha.puzzleY = d.puzzle_y || 0;
        captcha.puzzleSize = d.puzzle_size || 44;
        
        // 更新UI
        bg.src = d.bg_url;
        piece.src = d.slider_url;
        piece.style.width = captcha.puzzleSize + 'px';
        piece.style.height = captcha.puzzleSize + 'px';
        
        // 重置位置
        currentOffsetX = 0;
        piece.style.transform = `translate(0px, ${captcha.puzzleY}px)`;
        handle.style.transform = `translateX(0px)`;
    }
}

// 验证提交
async function verify() {
    const res = await api(base + '/web/captcha/verify1', {
        captcha_id: captcha.id,
        phone: phone,
        offset_x: currentOffsetX
    });
    
    const payload = unwrap(res);
    if (String(payload.code) === '200') {
        hint.textContent = '验证成功 ✓';
        // 处理成功逻辑
    } else {
        hint.textContent = '验证失败 ✗';
        // 处理失败逻辑
    }
}

安全性考虑

1. 防暴力破解

# 位置容错设置
TOLERANCE_PX = 6  # 允许6像素误差

# 验证逻辑
if abs(rec.puzzle_x - int(offset_x)) <= TOLERANCE_PX:
    # 验证通过
    pass

2. 会话管理

  • 唯一性保障:基于UUID4算法的128位随机标识符,在理论上提供2^122的唯一性空间,满足分布式系统的并发需求
  • 时效性控制:实施10分钟的会话生命周期管理,平衡用户操作便利性与安全风险控制
  • 一次性验证机制:采用原子性的状态更新操作,确保验证码的单次有效性,有效防范重放攻击

3. 频率限制

# 短信发送限制
OTP_COOLDOWN_SEC = 60     # 60秒冷却
OTP_MAX_PER_HOUR = 5      # 每小时5次
OTP_MAX_PER_DAY = 12      # 每日12次

4. 图像安全

  • 动态随机化生成:每次请求均生成独特的拼图形状、位置和背景模式,杜绝基于模板匹配的自动化攻击
  • 多层次视觉干扰:通过背景纹理、随机噪声、颜色渐变的复合干扰,提升机器视觉识别的复杂度
  • 边缘模糊化处理:采用抗锯齿技术模糊像素边界,有效对抗基于边缘检测的精确定位算法

性能优化

1. 图像处理优化

# 超采样抗锯齿
scale = 3  # 3倍超采样
m = m.filter(ImageFilter.GaussianBlur(0.6))
m = m.resize((size, size), Image.LANCZOS)

2. 缓存策略

  • 高效附件存储:充分利用Odoo的attachment存储机制,实现图像的去重存储和快速访问
  • 权限优化访问:将验证码图片设置为public模式,绕过复杂的权限验证流程,提升响应速度
  • CDN兼容设计:生成符合标准的静态资源URL,便于集成CDN加速服务,优化全球访问性能

3. 前端优化

// 防抖处理
let dragThrottle = false;
function onDragMove(clientX) {
    if (!dragging || dragThrottle) return;
    dragThrottle = true;
    requestAnimationFrame(() => {
        // 更新逻辑
        dragThrottle = false;
    });
}

部署与配置

1. Odoo模块结构

captcha_module/
├── __manifest__.py
├── controllers/
│   └── captcha_controller.py
├── models/
│   └── captcha_session.py
├── static/
│   └── templates/
│       └── test_captcha.html
└── security/
    └── ir.model.access.csv

2. 依赖安装

pip install Pillow aliyun-python-sdk-dysmsapi

3. 配置文件

# __manifest__.py
{
    'name': 'Slider Captcha',
    'version': '1.0.0',
    'depends': ['base', 'web'],
    'external_dependencies': {
        'python': ['PIL', 'aliyunsdkdysmsapi']
    },
    'data': [
        'security/ir.model.access.csv',
    ],
    'installable': True,
    'auto_install': False,
}

测试与验证

img
image


<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <title>滑块验证码(generate1 / verify1 测试页)</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <style>
    :root {
      --w: 300px; /* 背景宽度(需与后端一致) */
      --h: 200px; /* 背景高度(需与后端一致) */
    }
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
      background: #0b0c10;
      color: #e6edf3;
      margin: 0; padding: 24px;
    }
    .card {
      max-width: 760px;
      margin: 0 auto;
      background: #0f172a;
      border: 1px solid #1f2937;
      border-radius: 16px;
      box-shadow: 0 10px 30px rgba(0,0,0,.35);
      padding: 20px;
    }
    h1 {
      font-size: 18px; margin: 0 0 12px;
    }
    .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
    .row + .row { margin-top: 10px; }
    input[type="text"] {
      background: #0b1220;
      color: #e6edf3;
      border: 1px solid #1f2937;
      padding: 10px 12px;
      border-radius: 10px;
      outline: none;
      width: 220px;
    }
    button {
      background: #2563eb;
      color: white;
      border: none;
      padding: 10px 14px;
      border-radius: 10px;
      cursor: pointer;
      font-weight: 600;
    }
    button.secondary { background: #334155; }
    button:disabled { opacity: .6; cursor: not-allowed; }

    .stage {
      position: relative;
      width: var(--w);
      height: var(--h);
      border-radius: 12px;
      overflow: hidden;
      user-select: none;
      border: 1px solid #1f2937;
      background: #111827;
      margin-top: 12px;
    }
    .bg {
      position: absolute; inset: 0;
      width: 100%; height: 100%;
      object-fit: cover;
      filter: saturate(1.05);
    }
    .slider-piece {
      position: absolute;
      top: 0; left: 0;
      width: 44px; /* 初始占位;加载后会根据 puzzle_size 调整 */
      height: 44px;
      will-change: transform;
      pointer-events: auto;
      touch-action: none;
    }
    .track {
      margin-top: 12px;
      height: 40px;
      border-radius: 999px;
      background: #0b1220;
      border: 1px solid #1f2937;
      position: relative;
      display: flex;
      align-items: center;
      padding: 0 10px;
      gap: 8px;
    }
    .handle {
      width: 36px; height: 36px;
      border-radius: 50%;
      background: #2563eb;
      box-shadow: 0 8px 16px rgba(37,99,235,.35);
      border: 1px solid rgba(255,255,255,.15);
      display: grid; place-items: center;
      color: #fff; font-weight: 700;
      user-select: none;
      cursor: grab;
      touch-action: none;
    }
    .handle:active { cursor: grabbing; }
    .meta { margin-top: 12px; font-size: 12px; color: #9ca3af; }
    .log {
      margin-top: 16px;
      background: #0b1220;
      border: 1px solid #1f2937;
      border-radius: 10px;
      padding: 12px;
      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
      font-size: 12px;
      max-height: 260px; overflow: auto;
      white-space: pre-wrap; word-break: break-all;
      color: #d1d5db;
    }
    .row label { font-size: 13px; color: #cbd5e1; }
    .pill {
      display: inline-block;
      padding: 2px 8px;
      border: 1px solid #1f2937;
      border-radius: 999px;
      background: #0b1220;
      color: #cbd5e1;
      font-size: 12px;
      margin-left: 6px;
    }
  </style>
</head>
<body>
  <div class="card">
    <h1>滑块验证码测试页 <span class="pill">/web/captcha/generate1</span> <span class="pill">/web/captcha/verify1</span></h1>

    <div class="row">
      <label>后端基址:</label>
      <input id="baseUrl" type="text" placeholder="例如:https://your-odoo-host" />
      <button class="secondary" id="btnUseSame">用当前域名</button>
    </div>

    <div class="row">
      <label>手机号:</label>
      <input id="phone" type="text" placeholder="11位手机号" />
      <button id="btnGen">刷新验证码</button>
      <button id="btnVerify" class="secondary">提交验证</button>
    </div>

    <div class="stage" id="stage">
      <img id="bg" class="bg" alt="bg" />
      <img id="piece" class="slider-piece" alt="piece" draggable="false" />
    </div>

    <div class="track">
      <div class="handle" id="handle">↔</div>
      <div id="hint">按住圆钮拖动至正确位置</div>
    </div>

    <div class="meta" id="meta"></div>
    <div class="log" id="log"></div>
  </div>

  <script>
    const FIXED_BASE = 'http://127.0.0.1:8080';  // 或 'http://localhost:8080'
    const $ = (id)=>document.getElementById(id);
    const baseUrlInput = FIXED_BASE;
    const phoneInput = $('phone');
    const btnUseSame = $('btnUseSame');
    const btnGen = $('btnGen');
    const btnVerify = $('btnVerify');
    const bg = $('bg');
    const piece = $('piece');
    const handle = $('handle');
    const hint = $('hint');
    const meta = $('meta');
    const log = $('log');
    const stage = $('stage');

    // 运行时状态
    let captcha = {
      id: null,
      puzzleY: 0,
      puzzleSize: 44,
      // 真实答案 x(仅用于开发/对比;生产不显示)
      puzzleX: null
    };
    let dragging = false;
    let startX = 0;          // 鼠标/触摸起点
    let handleStartLeft = 0; // 拖拽开始时句柄的 left
    let currentOffsetX = 0;  // 提交给后端的 offset_x(与 piece 左上角对齐)

    function logLine(msg, obj) {
      const time = new Date().toLocaleTimeString();
      log.textContent = `[${time}] ${msg}` + (obj ? `\n${JSON.stringify(obj, null, 2)}` : '') + '\n\n' + log.textContent;
    }

    function sameOriginBase() {
      return window.location.origin.replace(/\/$/, '');
    }

    btnUseSame.onclick = ()=>{
      baseUrlInput.value = sameOriginBase();
    };

    function api(url, payload) {
      // Odoo type=json 路由:直接发 application/json
      return fetch(url, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(payload || {})
      }).then(async r=>{
        const data = await r.json().catch(()=>({}));
        return data;
      });
    }


    // 兼容直返({code,data})与JSON-RPC({result:{code,data}})
    function unwrap(resp) {
      if (!resp || typeof resp !== 'object') return {};
      if ('result' in resp && resp.result && typeof resp.result === 'object') return resp.result;
      return resp; // 已是直返
    }


    async function generate() {
      const base = FIXED_BASE;
      btnGen.disabled = true;
      btnVerify.disabled = true;
      hint.textContent = '加载中...';
      try {
        const res = await api(base + '/web/captcha/generate1', {});
        logLine('generate1 响应:', res);
        const payload = unwrap(res);        // <== 关键:统一取 result
        if (payload.code === 0 && payload.data) {
          const d = payload.data;
          captcha.id = d.captcha_id;
          captcha.puzzleY = d.puzzle_y || 0;
          captcha.puzzleSize = d.puzzle_size || 44;
          captcha.puzzleX = d.puzzle_x ?? null; // 仅开发辅助
          bg.src = d.bg_url;
          piece.src = d.slider_url;
          piece.style.width = captcha.puzzleSize + 'px';
          piece.style.height = captcha.puzzleSize + 'px';
          // 初始位置:x=0,y对齐 puzzle_y
          currentOffsetX = 0;
          piece.style.transform = `translate(${currentOffsetX}px, ${captcha.puzzleY}px)`;
          // 句柄回到起点
          handle.style.transform = `translateX(0px)`;
          hint.textContent = '按住圆钮拖动至正确位置';
          meta.textContent = `captcha_id=${captcha.id},puzzle_y=${captcha.puzzleY},puzzle_size=${captcha.puzzleSize}` +
            (captcha.puzzleX != null ? `,(dev: answerX=${captcha.puzzleX})` : '');
          btnVerify.disabled = false;
        } else {
          alert('生成失败:' + (res && res.msg || 'unknown'));
        }
      } catch (e) {
        console.error(e);
        alert('请求失败:' + e);
      } finally {
        btnGen.disabled = false;
      }
    }

    // 拖拽逻辑(鼠标 + 触摸)
    function onDragStart(clientX) {
      dragging = true;
      startX = clientX;
      const m = /translateX\(([-\d.]+)px\)/.exec(handle.style.transform || '');
      handleStartLeft = m ? parseFloat(m[1]) : 0;
      document.body.style.userSelect = 'none';
    }
    function onDragMove(clientX) {
      if (!dragging) return;
      const dx = clientX - startX;
      // 轨道有效范围:从 0 到 (背景宽 - 滑块宽)
      const maxX = stage.clientWidth - captcha.puzzleSize;
      let nx = Math.min(Math.max(handleStartLeft + dx, 0), maxX);
      handle.style.transform = `translateX(${nx}px)`;
      // slider piece 同步
      currentOffsetX = Math.round(nx); // 与后端像素栅格一致
      piece.style.transform = `translate(${currentOffsetX}px, ${captcha.puzzleY}px)`;
    }
    function onDragEnd() {
      dragging = false;
      document.body.style.userSelect = '';
    }

    // 事件绑定:鼠标
    handle.addEventListener('mousedown', (e)=> onDragStart(e.clientX));
    window.addEventListener('mousemove', (e)=> onDragMove(e.clientX));
    window.addEventListener('mouseup', onDragEnd);
    // 事件绑定:触摸
    handle.addEventListener('touchstart', (e)=>{
      const t = e.touches[0]; onDragStart(t.clientX);
    }, {passive:true});
    window.addEventListener('touchmove', (e)=>{
      if (!dragging) return;
      const t = e.touches[0]; onDragMove(t.clientX);
    }, {passive:true});
    window.addEventListener('touchend', onDragEnd);

    // 提交验证
    btnVerify.onclick = async ()=>{
      const base = FIXED_BASE;
      const phone = (phoneInput.value || '').trim();
      if (!captcha.id) return alert('请先生成验证码');
      if (!/^\d{11}$/.test(phone)) return alert('请输入 11 位手机号');
      btnVerify.disabled = true;
      hint.textContent = '提交验证中...';
      try {
        const res = await api(base + '/web/captcha/verify1', {
          captcha_id: captcha.id,
          phone: phone,
          offset_x: currentOffsetX
        });
        logLine('verify1 响应:', res);
        const payload = unwrap(res);
        if (String(payload.code) === '200') {
          hint.textContent = '验证成功 ✓';
          // 你的后端当前会回传 verifycode,这里仅测试显示:
          if (res.verifycode) {
            alert('验证成功,返回 verifycode(仅测试环境):' + res.verifycode);
          } else {
            alert('验证成功');
          }
        } else {
          hint.textContent = '验证失败 ✗';
          alert('验证失败:' + (res && (res.message || res.msg) || 'unknown'));
        }
      } catch (e) {
        console.error(e);
        alert('请求失败:' + e);
      } finally {
        btnVerify.disabled = false;
      }
    };

    // 刷新
    btnGen.onclick = generate;

    // 初始:默认使用当前域名
    baseUrlInput.value = sameOriginBase();
    // 页面加载自动生成一次
    generate();
  </script>
</body>
</html>






1. 功能测试

  • 图像生成验证:测试各种参数配置下的拼图和背景图像生成质量,确保视觉效果和技术指标的一致性
  • 跨设备交互测试:验证在PC端、移动端、平板设备上的拖拽操作体验,确保响应精度和操作流畅度
  • 精度校验测试:测试位置验证算法在不同容错阈值下的准确性和误判率,优化用户体验
  • 短信投递测试:验证短信服务的成功率、延迟和失败重试机制,确保验证码的可靠送达

2. 性能测试

  • 高并发压力测试:模拟大规模用户并发访问场景,验证系统的吞吐量和稳定性表现
  • 内存使用监控:持续监测图像处理过程中的内存分配和回收情况,识别潜在的内存泄露问题
  • API响应性能:测量验证码生成和校验接口的响应时间分布,确保满足用户体验要求

3. 安全测试

  • 自动化攻击防护:模拟各种自动化工具的攻击模式,验证系统的防护效果和识别准确率
  • 重放攻击防护:验证会话管理机制对重放攻击的防护能力,确保一次性验证的安全性
  • 频率限制有效性:测试多维度频率限制策略的执行效果,验证对恶意行为的拦截能力

总结与展望

本文从理论基础到工程实践,全面阐述了基于Odoo18框架构建企业级滑块验证码系统的完整技术路径。通过深入的算法设计、精细的工程实现和完善的安全策略,我们成功打造了一套既安全可靠又用户友好的现代化验证码解决方案。

核心技术成就

  1. 数学算法突破:创新性地应用三次贝塞尔曲线技术,实现了自然流畅的拼图形状生成,有效提升了机器识别的难度
  2. 图像处理优化:通过超采样抗锯齿、多层次纹理合成等先进技术,达到了专业级的视觉呈现效果
  3. 交互体验革新:构建了跨平台兼容的拖拽交互系统,为用户提供了直观流畅的操作体验
  4. 安全防护体系:建立了多维度、多层次的安全防护机制,有效抵御各种自动化攻击手段
  5. 性能工程优化:通过智能缓存、资源优化等技术手段,实现了高并发场景下的稳定服务能力

技术发展趋势

  1. 智能对抗升级:未来将集成更加复杂的视觉干扰算法和动态难度调节机制,持续提升抗AI识别能力
  2. 行为特征融合:计划引入用户行为模式分析技术,通过多维度特征融合提升验证准确性
  3. 无障碍体验优化:将增加语音辅助、键盘导航等无障碍功能,提升特殊用户群体的使用体验
  4. 全球化服务扩展:支持多语言界面和多地区短信服务,满足国际化应用的部署需求
  5. 数据驱动优化:建立完善的用户体验数据收集和分析体系,实现基于数据驱动的持续优化

应用价值总结

通过本系统的成功实施,我们不仅有效解决了传统验证码在安全性和用户体验方面的固有矛盾,更为企业级Web应用提供了一套可扩展、可维护的安全防护基础设施。该解决方案的技术架构和实现模式,为同类系统的开发提供了宝贵的参考价值和最佳实践指导。

posted @ 2025-08-16 11:29  何双新  阅读(88)  评论(0)    收藏  举报