第五十章 HTTP 控制器与 @http.route

篇别:第九篇 API 与系统集成

本章学习目标

  • 说明 http.Controller模块装载 时如何 注册路由,以及 @http.route普通 Python 方法请求线程 中的关系。
  • 逐参数 解释 routetypeauthmethodscsrfwebsitesave_session 等(以 Odoo 19 源码 odoo.http 为准),能根据业务场景 写出合法组合 并说明 副作用
  • 熟练使用 Werkzeug 风格路径<int:id><string:slug><path:...>)与 route=[...] 多 URL,正确从 request.params / httprequest 读取 query、表单、文件、原始 body
  • 对比 type='http'type='jsonrpc'协议封装、错误形态、客户端调用、缓存与网关 上的差异,能与 第三十六章、第三十八章 对照做 架构选型
  • 掌握 控制器继承追加 / 覆盖 路由的 维护策略,以及 request.env、ACL、sudoController 边界 上的 典型误用与修复
  • 能为 自定义路由 列出 安全清单,并用 HttpCase / curl回归点(与 第二十九章 衔接)。

导读:HTTP 边界上的「契约」与「责任」

浏览器地址栏、移动端 SDK、支付渠道回调、Webhook 推送 最终都会变成一次 HTTP 请求;在 Odoo 中,这类请求若不由 静态资源或标准 /web 路由 处理,就会进入你编写的 http.Controller 子类@http.route 的作用,是在 类装载阶段「URL 模式 + HTTP 动词 + 认证策略 + CSRF 策略」 绑定到 某个 Python 可调用对象 上——可以理解为 声明式契约:客户端按契约发请求,服务端按契约 解析参数、建立 request.env、返回 Response

本章在全书中的位置可概括为:

关联章节 本章与之的关系
第三十六章 第三十六章覆盖 WebClient 前端 URL、json→jsonrpc 迁移、安全清单入门;本章 专讲 @http.route 参数与路径语义,建议 先读 36 再读 50,或 两章对照
第三十八章 第三十八章讲 REST 资源形态、Webhook;本章讲 路由层如何承载 这些形态(同一 type='http' 可返回 REST JSONHTML)。
第三十章 CSRF、auth、越权底线在模型 ACL;Controller 里 sudo、按 id 查记录事故高发区,本章 50.6、50.10 与第三十章 对照
第二十九章 HttpCase路由自动化回归;本章 50.9 给出 可测点

阅读建议:准备 Postman / curl、开启 开发者工具 Network;对 jsonrpc 路由准备 最小 JSON-RPC 请求体(见 50.5)。


50.1 控制器类与路由注册

50.1.1 知识要点

  • http.Controller:所有 对外 HTTP 入口(在 Python 侧)通常 继承该类不写继承 而直接写函数 不会 自动注册为 Odoo 路由(必须使用装饰器挂在 Controller 方法上)。
  • 模块安装与升级:Controller 所在模块 import 被加载时,装饰器副作用路由规则 登记到 全局路由表(具体数据结构以 odoo.http/odoo.addons.web 为准)。-u 模块名 后若 未出现预期路由,优先检查:文件是否在 controllers/__init__.py 中被导入
  • 线程与请求:每个 HTTP 请求在 工作线程 中执行;request线程局部 上下文(不要在 Controller 外长期持有 request 引用 跨线程使用)。
  • 命名空间类名 仅用于 代码组织URL 唯一性route 字符串 决定,与类名无关。多模块定义 相同 route 时,后加载者覆盖或并存以启动日志与实测为准,见 50.8)。

50.1.2 案例

案例 A:最小模块结构

my_library/
  __init__.py          # from . import controllers
  controllers/
    __init__.py        # from . import main
    main.py            # class LibraryController(http.Controller): ...

controllers/__init__.py

from . import main

controllers/main.py

from odoo import http
from odoo.http import request

class LibraryController(http.Controller):
    @http.route("/library/health", type="http", auth="none", methods=["GET"], csrf=False)
    def health(self):
        return request.make_json_response({"module": "my_library", "status": "ok"})

案例 B:为何 auth='none' 仍可能「有环境」

request.env 在部分场景下仍可通过 sudo()集成用户 构造;auth='none' 表示 不强制登录会话不等于「无任何 env」。业务数据访问 仍须 显式鉴权(见 50.6)。

50.1.3 截图占位

图 50-1 模块结构与 controllers 包被 __init__ 导入

图 50-2 升级模块后浏览器访问 /library/health 的 JSON

50.1.4 本节练习

  1. 简答:若 controllers/main.py 存在但 未在 controllers/__init__.py 中 import,症状是什么?(提示:路由 404
  2. 判断同一 Odoo 进程 中,两个模块 注册 完全相同route 字符串合法且可预测 的。( )
  3. 实操:新建 最小 Controller,返回 {"ok": true},用 curl 验证 GET

参考答案提示:2. 不保证;依赖 加载顺序生产环境应避免


50.2 @http.route 装饰器参数(总览)

50.2.1 知识要点

以下列出 教学用 参数说明;精确默认值、新增关键字 请以 当前版本 odoo.http.route / Request 为准(可在 源码官方 Reference 中核对)。

参数(常见) 含义摘要
route 字符串字符串列表;列表表示 多 URL 共用一个处理函数
type 'http':普通 HTTP;'jsonrpc'JSON-RPC 2.0 封装(Odoo 19 使用 jsonrpc 替代旧式 json)。
auth user:须 已登录后台用户public:允许 匿名(网站访客等);none不自动依赖登录会话须自证身份)。
methods ['GET','POST']限制动词 可避免 误用 GET 改状态
csrf True校验 CSRF token第三十章);False 仅在有 替代防护 时使用。
website True 时走 网站相关中间件与 QWeb 上下文前台主题、访客会话 等);纯 APIFalse
save_session 是否 写 session无状态 APIHMAC 回调 可能 False(须 评估风控)。

组合直觉非绝对):

  • 后台按钮触发的 JSON:常 auth='user' + csrf=True(或 type=jsonrpcRPC 通道)。
  • 对外 Webhook:常 auth='none'public' + csrf=False + 签名 / IP 白名单**。
  • 网站页面:常 type='http' + website=True

50.2.2 案例

@http.route(
    ["/shop/api/cart", "/shop/api/v2/cart"],
    type="http",
    auth="public",
    methods=["GET", "POST"],
    csrf=False,  # 若 POST 来自第三方,需 HMAC 等替代
    website=True,
)
def cart_api(self, **kw):
    ...

50.2.3 截图占位

图 50-3 官方文档或源码中 route 装饰器签名

50.2.4 本节练习

  1. 简答website=TrueFalse同一路径模板渲染上下文 可能有何影响?
  2. 判断csrf=False仍应 使用 POST 传输敏感参数。( )

参考答案提示:2. 仍应避免 将密钥放 URL;POST + HTTPS基础csrf=False补偿控制


50.3 URL 路径、多路由与 Werkzeug 转换器

50.3.1 知识要点

  • 路径规则 遵循 Werkzeug routing<converter:variable_name>。常见转换器包括 intstringfloatpath完整列表以 Werkzeug 文档为准)。
  • <int:book_id>:匹配 非负整数;函数参数 book_id 自动注入
  • <string:slug>:默认 不包含斜杠<path:filepath>跨多级路径附件下载、静态代理),须防目录穿越规范化、白名单后缀)。
  • 多路由route=["/a", "/b"] 适合 版本并存、兼容旧客户端日志与监控 建议打 统一 endpoint 避免 指标分裂
  • 末尾斜杠/foo/foo/ 是否等价取决于 Werkzeug/Odoo 配置生产环境 建议 定一种规范 并在 反向代理 层统一。

50.3.2 案例

案例 A:整数 id + 404

@http.route("/library/book/<int:book_id>", type="http", auth="user", methods=["GET"], website=True)
def book_public(self, book_id, **kw):
    book = request.env["library.book"].browse(book_id)
    if not book.exists():
        return request.not_found()
    return request.render("library.book_page", {"book": book})

案例 B:query 参数

@http.route("/library/search", type="http", auth="public", methods=["GET"], website=True)
def search(self, q="", limit=20, **kw):
    limit = min(int(limit), 100)
    q = (q or "").strip()
    ...

50.3.3 截图占位

图 50-4 浏览器访问带路径参数与 query 的 Network 面板

50.3.4 本节练习

  1. 实操:实现 GET /library/searchq 为空 时返回 400 JSON {"error":"empty_query"}
  2. 简答<path:name><string:name> 匹配 /a/b/c 时分别截取到哪?
  3. 判断路径转换器自动 防止 SQL 注入。( )

参考答案提示:3. 注入 发生在 字符串拼 SQL 时,与 路径无关


50.4 request:Query、表单、文件与 JSON body

50.4.1 知识要点

  • request.params合并query stringPOST 表单具体合并规则以版本为准);教学上 可理解为 「一次取参」 的便捷入口。
  • request.httprequest.args仅 queryrequest.httprequest.form仅表单字段调试 时可 分项打印 避免 混淆
  • JSON body(type='http':需 request.httprequest.get_data() / datajson.loads须 try/except错误返回 400解析栈 返回客户端。
  • 文件上传request.httprequest.filesWerkzeug FileStorage);大文件 注意 worker 超时磁盘临时路径
  • Content-Typeapplication/jsontext/plain 行为不同;客户端错设 会导致 body 读不到

50.4.2 案例

import json
from odoo import http
from odoo.http import request

class LibraryApi(http.Controller):
    @http.route("/library/api/note", type="http", auth="user", methods=["POST"], csrf=True, website=False)
    def save_note(self, **kw):
        try:
            payload = json.loads(request.httprequest.data.decode("utf-8"))
        except (ValueError, AttributeError):
            return request.make_json_response({"error": "invalid_json"}, status=400)
        text = (payload.get("text") or "").strip()
        ...
        return request.make_json_response({"ok": True})

50.4.3 截图占位

图 50-5 Postman 中 POST JSON 与 form 对比

50.4.4 本节练习

  1. 简答request.params同名键 同时出现在 query 与 body 时,应以何为准?(提示:查版本文档或写单测
  2. 实操:为 save_noteHttpCase非法 JSON → 400

50.5 type='http'type='jsonrpc'(深度对比)

50.5.1 知识要点

维度 type='http' type='jsonrpc'
协议 裸 HTTP;body 任意(HTML/JSON/二进制 JSON-RPC 2.0 信封(jsonrpcmethodparamsid
典型客户端 curl、任意 HTTP SDK、浏览器 fetch Odoo WebClient rpc、自定义 JSON-RPC 客户端
返回值 自行 make_json_response / render / Response 业务方法通常 return dict,由 RPC 层包装
错误 HTTP 状态码 + 自定义 body JSON-RPC error 对象与 HTTP 状态 的组合 以版本为准
适用 对外 REST、文件、Webhook、支付回调 与后端同一套 JSON-RPC 约定的内部调用
  • 迁移:Odoo 19 将旧 type='json' 迁移为 'jsonrpc'第三十六章);全仓库 grep逐条测试
  • 反模式:用 jsonrpc 路径 假装 REST 资源第三方网关 使用——文档与缓存策略很痛苦对外 优先 明确 REST(第三十八章)

50.5.2 案例(jsonrpc)

class LibraryJson(http.Controller):
    @http.route("/library/jsonrpc/stats", type="jsonrpc", auth="user")
    def stats(self):
        Book = request.env["library.book"]
        return {"count": Book.search_count([])}

50.5.3 截图占位

图 50-6 浏览器或工具发送 JSON-RPC 请求体

50.5.4 本节练习

  1. 简答UserErrorjsonrpchttp+make_json_response 两条链路上,用户可见提示 可能分别由谁 格式化
  2. 判断jsonrpc 不能 返回 文件流。( )

参考答案提示:2. 一般 不走 文件流;若需 导出大文件,用 type='http'附件 / 异步任务


50.6 authcsrfwebsite 与安全边界

50.6.1 知识要点

  • auth='user'须登录request.env.user当前用户记录规则与 ACL 生效(除非 sudo())。
  • auth='public'允许未登录网站访客 可能有 public 用户或 partner 相关语义(以 website 模块为准)。仍可能env勿默认 env 为空
  • auth='none'不自动建立登录用户上下文典型 用于 机器间签名回调数据访问 必须用 Token / HMAC / API Key 自建 信任链
  • csrf浏览器表单 POST部分 AJAX 依赖 token开放 Webhookcsrf=False,但必须 验证签名或来源 IP
  • Controller 中的 sudo()绕过记录规则仅当 你已 用其他方式证明「该 id 属于该调用方」。反面模式browse(user_id) 来自 query 参数未校验

50.6.2 案例(反面→正面)

反面(示意,禁止照抄到生产)

@http.route("/library/insecure/<int:user_id>", type="http", auth="none", methods=["GET"], csrf=False)
def bad(self, user_id, **kw):
    partner = request.env["res.partner"].sudo().browse(user_id)
    return request.make_json_response({"email": partner.email})

正面思路auth='user' 下仅允许 user_id == request.env.user.partner_id.id,或 使用 signed token一次性下载权

50.6.3 截图占位

图 50-7 设置 → 技术 → 会话与 Cookie 相关说明(示意)

50.6.4 本节练习

  1. 简答支付渠道 异步 POST 回调 为何常 csrf=False补偿手段 列举 两条
  2. 实操:给 auth='user' 接口用 隐身窗口 访问,记录 HTTP 状态302/401/403 之一)。

50.7 返回值、重定向与错误页

50.7.1 知识要点

  • request.make_json_response(data, status=200, headers=None)JSON统一错误体{"error":{"code":...}}第三十八章)。
  • request.render(qweb_template, values)QWebwebsite=True 时常 需要 正确的 上下文与资产
  • request.not_found()404SEO监控区分业务不存在」与「路由未注册」。
  • request.redirect(url, local=True/False)302/303开放重定向 漏洞: url 来自用户输入须校验域名或路径
  • 异常UserErrorAccessError不同 type 下表现不同;生产 勿向客户端 打印 Python traceback

50.7.2 案例

@http.route("/library/legacy", type="http", auth="public", website=True)
def legacy_redirect(self, **kw):
    return request.redirect("/library/new-catalog", local=True)

50.7.3 截图占位

图 50-8 404 与 JSON 错误体在 Network 中的对比

50.7.4 本节练习

  1. 简答local=Trueredirect 限制什么?为何能 缓解开放重定向
  2. 判断make_json_responsestatus=500 适合 所有业务错误。( )

参考答案提示:2. 不宜业务可预期错误 常用 4xx5xx 保留给 未捕获异常


50.8 控制器继承、路由覆盖与模块前缀

50.8.1 知识要点

  • 继承 http.Controller子类新增方法新路由)或 重写父类同名方法覆盖行为)。
  • 加载顺序依赖 depends 与模块列表后装载同名 route 可能 覆盖 先前注册项——升级后务必回归
  • 命名空间推荐 /<my_module>/... 前缀;避免 占用 /web/website 核心前缀 除非 维护官方补丁
  • 测试HttpCase关键路径冒烟多版本并存 时测 旧 URL 仍可用

50.8.2 案例(仅示意)

class LibraryController(http.Controller):
    @http.route("/my_library/ping", type="http", auth="public", methods=["GET"], csrf=False)
    def ping(self):
        return request.make_json_response({"v": 1})

class LibraryControllerV2(LibraryController):
    @http.route("/my_library/ping", type="http", auth="public", methods=["GET"], csrf=False)
    def ping(self):
        return request.make_json_response({"v": 2})

50.8.3 本节练习

  1. 简答为何 不推荐在 无命名空间 下覆写 /web/session/... 一类路径?
  2. 实操grep 全仓库 @http.route("/web,评估 是否必须

50.9 调试、日志与常见故障

50.9.1 知识要点

现象 可能原因 排查
404 模块未加载路由拼写末尾斜杠多 db / dbfilter 日志--log-level=debugcurl -v
415 / 无法解析 Content-Type 错误、jsonrpc 体格式 不对 对比 官方 JSON-RPC 样例
400 CSRF POST 无 token跨站 同站点带 tokencsrf=False + 补偿
302 到登录 auth='user' 未登录 Session cookie域名
500 未捕获异常env 误用 服务端 tracebackSentry
  • 日志Controller 入口结构化日志route、user、耗时), 记录 密码、token
  • Worker 超时大文件 / 慢 SQLController 同步执行拖住 worker长任务队列 / cron第二十一章)。

50.9.2 本节练习

  1. 实操:故意 POST 错误 JSON,确认 返回 400日志无 traceback(若你捕获了)。
  2. 简答多 workersession 粘滞 问题对 哪些路由 影响最大?

50.10 本章小结:路由层检查清单

50.10.1 知识要点(清单)

  1. route 唯一且带模块前缀禁止 依赖 未定义 的覆盖顺序。
  2. methods 显式改状态POST/PUT/DELETE避免 GET
  3. auth 与业务 一致;sudo配对 业务校验
  4. csrf=False配对 签名 / 白名单 / 幂等
  5. jsonjsonrpc全量迁移并测试第三十六章)。
  6. HttpCase / 监控 覆盖 核心路径

50.10.2 截图占位

图 50-9 检查清单打印版或 Wiki 截图

50.10.3 本节练习

  1. 简答:用 不超过五条 话向 产品经理 解释 为何支付回调 不能 只靠 auth='user'
  2. 判断路由层限流多余 的,应 只在 Nginx 做。( )

参考答案提示:2. 应用层限流 可结合 用户、租户、业务键


50.11 参考案例:小程序 / 移动端 JSON 登录(改编)

下列案例 改编自 实际项目中的 微信小程序登录code 换 session、手机号解密、按手机号匹配或创建用户),已做 匿名化、删减业务字段、拆分安全注意不可直接复制进生产;仅用于理解 auth='none' + csrf=False + JSON 体 的常见组合。

50.11.1 场景与路由选择

  • 客户端:小程序 HTTPS无 Odoo 会话 Cookie → 通常 auth='none',由 微信侧 code / 手机号凭证 建立信任。
  • CSRF非浏览器同源表单 → 常 csrf=False补偿HTTPS微信 code 一次性服务端换 openid限流勿在日志打 code
  • type:历史代码常见 type='json'(旧版);Odoo 19type='jsonrpc' 或改用 type='http' + 自解析 JSON第三十六章)。下例 Ahttp + JSON,便于 与任意移动端约定同一响应体B 给出 jsonrpc参数形态 提示。

50.11.2 案例 A:type='http' + JSON 体(推荐用于「自定义 REST 形态」)

要点get_json_data() 并非所有版本均有;稳妥写法json.loads(request.httprequest.data)校验 Content-Type

import json
import logging
import requests
from odoo import http
from odoo.http import request

_logger = logging.getLogger(__name__)


def _call_wechat_jscode2session(js_code, appid, secret):
    """示意:向微信 jscode2session 换 openid(勿在生产 log 中打印 js_code)。"""
    url = (
        "https://api.weixin.qq.com/sns/jscode2session"
        f"?appid={appid}&secret={secret}&js_code={js_code}&grant_type=authorization_code"
    )
    resp = requests.get(url, timeout=8)
    data = resp.json()
    if data.get("errcode"):
        return {"ok": False, "err": data}
    return {"ok": True, "openid": data.get("openid"), "session_key": data.get("session_key")}


class MiniappAuthController(http.Controller):
    @http.route(
        "/myapp/mini/v1/login",
        type="http",
        auth="none",
        methods=["POST"],
        csrf=False,
    )
    def mini_login_json(self, **kw):
        """请求体示例:{"loginCode": "...", "phoneCode": "..."} — 字段名与小程序端对齐即可。"""
        try:
            payload = json.loads(request.httprequest.data.decode("utf-8"))
        except (ValueError, UnicodeDecodeError):
            return request.make_json_response({"code": 400, "message": "invalid_json"}, status=400)

        js_code = (payload.get("loginCode") or "").strip()
        phone_code = (payload.get("phoneCode") or "").strip()
        if not js_code or not phone_code:
            return request.make_json_response({"code": 400, "message": "missing_fields"}, status=400)

        icp = request.env["ir.config_parameter"].sudo()
        appid = icp.get_param("wechat.miniapp.appid")
        secret = icp.get_param("wechat.miniapp.secret")
        if not appid or not secret:
            _logger.error("mini login: missing ir.config_parameter")
            return request.make_json_response({"code": 500, "message": "server_misconfigured"}, status=500)

        wx = _call_wechat_jscode2session(js_code, appid, secret)
        if not wx.get("ok"):
            return request.make_json_response(
                {"code": 502, "message": "wechat_error", "detail": wx.get("err")},
                status=502,
            )

        openid = wx["openid"]
        # 此处接:get_access_token → getuserphonenumber(phoneCode) → 得到 phone(失败则 return,勿继续)
        # phone = ...  # str;下面仅为「查/建用户」示意,字段请按你方 res.users 定义裁剪
        # Users = request.env["res.users"].sudo()
        # user = Users.search([("login", "=", phone)], limit=1) or Users.create({...})

        return request.make_json_response(
            {"code": 200, "data": {"openid": openid}, "message": "openid_ok"}
        )

改编说明(对照「原始写法」时请关注):

原始常见写法 教学上建议
type='json' Odoo 19 改为 jsonrpc 或本例 http + JSON
cors='*' 慎用生产白名单 Origin由反向代理处理 CORS
大量 sudo() + 按手机号/ openid 建用户 必须 配合 防刷、限流、审计默认密码写死在代码 中。
print(resp) 改为 _logger.debug脱敏
业务全堆在 Controller 换 openid、建用户、建业务档案下沉 Model / Service,Controller 只做参数校验与编排

补充:你方若还有 「仅手机号 + openid 二次登录」 等第二条路由,可仿 /myapp/mini/v1/login 再声明 @http.route共用 同一套 查用户 函数,避免 复制粘贴大段 create

50.11.3 案例 B:type='jsonrpc'(与 WebClient 同一协议栈)

若希望 与 Odoo JSON-RPC 客户端 一致,可将 参数 放在 params 对象 中;方法形参 常与 params 对应(以实际版本为准)。

class MiniappAuthJsonRpc(http.Controller):
    @http.route("/myapp/mini/jsonrpc/login", type="jsonrpc", auth="none", csrf=False)
    def mini_login_rpc(self, login_code=None, phone_code=None):
        # login_code / phone_code 来自 JSON-RPC params
        if not login_code:
            return {"code": 400, "message": "missing login_code"}
        # ... 同案例 A 的换 openid / 换手机号 / 查建用户逻辑 ...
        return {"code": 200, "data": {"user_id": 1}}

50.11.4 截图占位

图 50-10 小程序开发者工具中发起 POST 与响应体

50.11.5 本节练习

  1. 简答为何「手机号 + openid」双因子 仍建议在 服务端 分别向 微信 API 校验,而 只信客户端提交的 openid 字符串
  2. 实操:在 案例 A 中为 /myapp/mini/v1/login 增加 每分钟 IP 限流应用内计数或 Redis,二选一思路即可)。
  3. 判断auth='none' 下使用 sudo() 创建用户 总是安全 的。( )

参考答案提示:3. sudo 绕过规则, 业务层证明 code/phone 来源可信。


50.11.6 参考案例 C:手机验证码登录(发送验证码 + 校验登录)

你给的原始代码链路是典型的:

  1. /sms/verifycode 生成验证码并发送短信;
  2. /verifycode/login 用手机号 + 验证码完成登录。

下面给出 教材改编版(保留流程,做了适配 Odoo 19 与安全改动):

  • 将旧写法 type='json' 改为 type='jsonrpc'(与第三十六章一致)。
  • 去掉 cors='*' 的默认开放示例(生产建议白名单)。
  • 统一错误码语义(400 参数、401 验证失败、429 频率限制、500 服务错误)。
  • 验证码只保留 摘要(hash)与过期时间,不在日志或响应体回传明文。
  • 演示逻辑使用独立模型字段名(如 sms_code_hash / sms_code_expire_at),避免和历史项目强耦合。
import datetime
import hashlib
import random
from odoo import http
from odoo.http import request


def _gen_sms_code():
    return "".join(str(random.randint(0, 9)) for _ in range(6))


def _hash_code(phone, code):
    # 教学示意:生产建议加 server-side salt / pepper
    return hashlib.sha256(f"{phone}:{code}".encode("utf-8")).hexdigest()


class MobileAuthController(http.Controller):
    @http.route("/myapp/sms/verifycode", type="jsonrpc", auth="public", csrf=False, methods=["POST"])
    def sms_verifycode(self, appid=None, phone=None):
        if not appid:
            return {"code": 400, "message": "missing appid", "data": {}}
        if not phone or len(str(phone)) != 11:
            return {"code": 400, "message": "invalid phone", "data": {}}

        Users = request.env["res.users"].sudo()
        user = Users.search([("login", "=", str(phone))], order="id desc", limit=1)
        if not user:
            # 可选:首次发送验证码时创建“待激活用户”;也可不创建,仅发码
            partner = request.env["res.partner"].sudo().create({"name": f"Mobile:{phone}"})
            user = Users.with_context(no_reset_password=True).create(
                {"name": f"用户{phone}", "login": str(phone), "partner_id": partner.id}
            )

        code = _gen_sms_code()
        expire_at = datetime.datetime.now() + datetime.timedelta(minutes=5)
        user.write(
            {
                "sms_code_hash": _hash_code(phone, code),
                "sms_code_expire_at": expire_at,
                "sms_code_try_count": 0,
            }
        )

        # 发送短信(示意):AliyunSms.send_sms(phone, {"code": code}, ...)
        # 生产建议:失败时不要返回网关原始异常栈,写日志 + 返回统一错误
        sent_ok = True
        if not sent_ok:
            return {"code": 500, "message": "sms_send_failed", "data": {}}

        return {"code": 200, "message": "success", "data": {}}

    @http.route("/myapp/verifycode/login", type="jsonrpc", auth="public", csrf=False, methods=["POST"])
    def verifycode_login(self, phone=None, verifycode=None):
        if not phone or not verifycode:
            return {"code": 400, "message": "missing phone or verifycode", "data": {}}

        Users = request.env["res.users"].sudo()
        user = Users.search([("login", "=", str(phone))], order="id desc", limit=1)
        if not user:
            return {"code": 401, "message": "account_not_found", "data": {}}

        if not user.sms_code_expire_at or datetime.datetime.now() > user.sms_code_expire_at:
            return {"code": 401, "message": "verifycode_expired", "data": {}}

        expect = user.sms_code_hash or ""
        actual = _hash_code(phone, str(verifycode))
        if expect != actual:
            tries = (user.sms_code_try_count or 0) + 1
            user.write({"sms_code_try_count": tries})
            if tries >= 5:
                return {"code": 429, "message": "too_many_attempts", "data": {}}
            return {"code": 401, "message": "verifycode_incorrect", "data": {}}

        # 登录成功:清理验证码,更新在线状态(字段名按你项目实际模型调整)
        user.write(
            {
                "sms_code_hash": False,
                "sms_code_expire_at": False,
                "sms_code_try_count": 0,
                "wechat_alive": "online",
            }
        )
        return {
            "code": 200,
            "message": "success",
            "data": {
                "user_id": user.id,
                "user_name": user.name or "",
                "institutions_id": user.institutions_id.id if user.institutions_id else False,
                "institutions_name": user.institutions_id.name if user.institutions_id else "",
            },
        }

改编重点(对照你原案例)

原案例习惯 教材版建议
type='json' Odoo 19 统一迁移到 jsonrpc(或 http+JSON)
cors='*' 默认不开放;按域名白名单配置
明文验证码可回显 仅存 hash,不回传验证码
验证码错误不限次 增加 sms_code_try_count 与锁定策略
多处重复创建用户/老人档案 抽成服务方法,Controller 只编排
异常直接 str(e) 返回前端 日志落地,前端返回统一错误码

50.11.7 配套菜单(后台运维入口)

移动端验证码登录本身走 HTTP/JSON 路由,不会自动出现在 Odoo 左侧业务菜单。

为了便于运营与排障,通常会在后台增加一组菜单:短信模板发送日志验证码登录日志风控黑名单

A. 菜单与 Action 示例(XML)

<!-- 顶级菜单:仅系统管理员可见 -->
<menuitem id="menu_mobile_auth_root"
          name="移动端登录"
          parent="base.menu_custom"
          sequence="90"
          groups="base.group_system"/>

<!-- 验证码日志 -->
<record id="action_sms_code_log" model="ir.actions.act_window">
    <field name="name">验证码发送日志</field>
    <field name="res_model">sms.code.log</field>
    <field name="view_mode">tree,form</field>
    <field name="context">{}</field>
</record>
<menuitem id="menu_sms_code_log"
          name="验证码发送日志"
          parent="menu_mobile_auth_root"
          action="action_sms_code_log"
          sequence="10"
          groups="base.group_system"/>

<!-- 登录日志 -->
<record id="action_mobile_login_log" model="ir.actions.act_window">
    <field name="name">手机号登录日志</field>
    <field name="res_model">mobile.login.log</field>
    <field name="view_mode">tree,form</field>
</record>
<menuitem id="menu_mobile_login_log"
          name="手机号登录日志"
          parent="menu_mobile_auth_root"
          action="action_mobile_login_log"
          sequence="20"
          groups="base.group_system"/>

<!-- 风控黑名单 -->
<record id="action_mobile_login_blacklist" model="ir.actions.act_window">
    <field name="name">登录黑名单</field>
    <field name="res_model">mobile.login.blacklist</field>
    <field name="view_mode">tree,form</field>
</record>
<menuitem id="menu_mobile_login_blacklist"
          name="登录黑名单"
          parent="menu_mobile_auth_root"
          action="action_mobile_login_blacklist"
          sequence="30"
          groups="base.group_system"/>

B. 配置菜单建议

  • 参数类(如短信签名、模板号、限流阈值)建议放在:

    设置 → 技术 → 系统参数ir.config_parameter)或单独配置模型。
  • 若有多公司差异(不同公司短信模板/签名),建议改为 公司维度配置模型,不要全塞系统参数。

C. 与路由联动

建议在 /myapp/sms/verifycode/myapp/verifycode/login 里写日志到上述模型(成功/失败、失败原因、IP、UA、手机号脱敏值),这样菜单可以直接用于:

  1. 追查某手机号为什么总是登录失败;
  2. 识别高频请求并封禁;
  3. 评估短信渠道稳定性(成功率、耗时、错误码分布)。

本章综合练习

  1. 实现GET /api/v1/library/books/<int:book_id>type='http'auth='none' + X-API-Key 校验(与第三十八章风格一致);404 / 200 JSON 结构明确。
  2. 实现:同资源再提供 jsonrpc/library/jsonrpc/book_readparams 含 id,返回 read() 字段对比 两种客户端 调用代码量
  3. 安全:列出 本模块所有 csrf=False 路由,逐条补偿措施无则改设计)。
  4. 迁移全仓库 grep "type='json'",输出 文件:行号计划替换为 jsonrpc 的回归用例
  5. 综合:画 一张 从浏览器到 Controller 再到 ORM数据流手绘拍照或 Mermaid 均可),标出 authcsrfrule 作用点。

与第三十六章的关系:第三十六章侧重 前端 URL、json→jsonrpc 迁移、安全清单;本章侧重 @http.route 契约、路径与 request、http/jsonrpc 对比、排错。建议 两章联读,并在 项目 Wiki 中维护 「路由注册表」URL、auth、csrf、负责人)。

本章对应白皮书目录:第五十章 HTTP 控制器与 @http.route