第五十章 HTTP 控制器与 @http.route
篇别:第九篇 API 与系统集成
本章学习目标
- 说明
http.Controller在 模块装载 时如何 注册路由,以及@http.route与 普通 Python 方法 在 请求线程 中的关系。 - 逐参数 解释
route、type、auth、methods、csrf、website、save_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、sudo 在 Controller 边界 上的 典型误用与修复。 - 能为 自定义路由 列出 安全清单,并用 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 JSON 或 HTML)。 |
| 第三十章 | 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.4 本节练习
- 简答:若
controllers/main.py存在但 未在controllers/__init__.py中 import,症状是什么?(提示:路由 404) - 判断:同一 Odoo 进程 中,两个模块 注册 完全相同 的
route字符串 是 合法且可预测 的。( ) - 实操:新建 最小 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 上下文(前台主题、访客会话 等);纯 API 常 False。 |
save_session |
是否 写 session;无状态 API、HMAC 回调 可能 False(须 评估风控)。 |
组合直觉(非绝对):
- 后台按钮触发的 JSON:常
auth='user'+csrf=True(或type=jsonrpc走 RPC 通道)。 - 对外 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.2.4 本节练习
- 简答:
website=True与False对 同一路径 的 模板渲染上下文 可能有何影响? - 判断:
csrf=False时 仍应 使用 POST 传输敏感参数。( )
参考答案提示:2. 仍应避免 将密钥放 URL;POST + HTTPS 是 基础,csrf=False 须 补偿控制。
50.3 URL 路径、多路由与 Werkzeug 转换器
50.3.1 知识要点
- 路径规则 遵循 Werkzeug routing:
<converter:variable_name>。常见转换器包括int、string、float、path(完整列表以 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.3.4 本节练习
- 实操:实现
GET /library/search:q为空 时返回400JSON{"error":"empty_query"}。 - 简答:
<path:name>与<string:name>匹配/a/b/c时分别截取到哪? - 判断:路径转换器 能 自动 防止 SQL 注入。( )
参考答案提示:3. 错;注入 发生在 字符串拼 SQL 时,与 路径无关。
50.4 request:Query、表单、文件与 JSON body
50.4.1 知识要点
request.params:合并 了 query string 与 POST 表单(具体合并规则以版本为准);教学上 可理解为 「一次取参」 的便捷入口。request.httprequest.args:仅 query;request.httprequest.form:仅表单字段;调试 时可 分项打印 避免 混淆。- JSON body(
type='http'):需request.httprequest.get_data()/data再json.loads;须 try/except,错误返回 400,勿 把 解析栈 返回客户端。 - 文件上传:
request.httprequest.files(Werkzeug FileStorage);大文件 注意 worker 超时 与 磁盘临时路径。 Content-Type:application/json与text/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.4.4 本节练习
- 简答:
request.params中 同名键 同时出现在 query 与 body 时,应以何为准?(提示:查版本文档或写单测) - 实操:为
save_note写HttpCase:非法 JSON → 400。
50.5 type='http' 与 type='jsonrpc'(深度对比)
50.5.1 知识要点
| 维度 | type='http' |
type='jsonrpc' |
|---|---|---|
| 协议 | 裸 HTTP;body 任意(HTML/JSON/二进制) | JSON-RPC 2.0 信封(jsonrpc、method、params、id) |
| 典型客户端 | 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.5.4 本节练习
- 简答:
UserError在jsonrpc与http+make_json_response两条链路上,用户可见提示 可能分别由谁 格式化? - 判断:jsonrpc 不能 返回 文件流。( )
参考答案提示:2. 一般 不走 文件流;若需 导出大文件,用 type='http' 或 附件 / 异步任务。
50.6 auth、csrf、website 与安全边界
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;开放 Webhook 常csrf=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.6.4 本节练习
- 简答:支付渠道 异步 POST 回调 为何常
csrf=False?补偿手段 列举 两条。 - 实操:给
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):QWeb;website=True时常 需要 正确的 上下文与资产。request.not_found():404;SEO 与 监控 应 区分「业务不存在」与「路由未注册」。request.redirect(url, local=True/False):302/303;开放重定向 漏洞:url来自用户输入 时 须校验域名或路径。- 异常:
UserError、AccessError在 不同 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.7.4 本节练习
- 简答:
local=True的 redirect 限制什么?为何能 缓解开放重定向? - 判断:
make_json_response的status=500适合 所有业务错误。( )
参考答案提示:2. 不宜;业务可预期错误 常用 4xx;5xx 保留给 未捕获异常。
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 本节练习
- 简答:为何 不推荐在 无命名空间 下覆写
/web/session/...一类路径? - 实操:grep 全仓库
@http.route("/web,评估 是否必须。
50.9 调试、日志与常见故障
50.9.1 知识要点
| 现象 | 可能原因 | 排查 |
|---|---|---|
| 404 | 模块未加载、路由拼写、末尾斜杠、多 db / dbfilter | 日志、--log-level=debug、curl -v |
| 415 / 无法解析 | Content-Type 错误、jsonrpc 体格式 不对 | 对比 官方 JSON-RPC 样例 |
| 400 CSRF | POST 无 token、跨站 | 同站点带 token 或 csrf=False + 补偿 |
| 302 到登录 | auth='user' 未登录 |
Session cookie、域名 |
| 500 | 未捕获异常、env 误用 | 服务端 traceback、Sentry |
- 日志:Controller 入口 打 结构化日志(route、user、耗时),勿 记录 密码、token。
- Worker 超时:大文件 / 慢 SQL 在 Controller 同步执行 会 拖住 worker;长任务 用 队列 / cron(第二十一章)。
50.9.2 本节练习
- 实操:故意 POST 错误 JSON,确认 返回 400 且 日志无 traceback(若你捕获了)。
- 简答:多 worker 下 session 粘滞 问题对 哪些路由 影响最大?
50.10 本章小结:路由层检查清单
50.10.1 知识要点(清单)
route唯一且带模块前缀;禁止 依赖 未定义 的覆盖顺序。methods显式;改状态 用 POST/PUT/DELETE,避免 GET。auth与业务 一致;sudo必 配对 业务校验。csrf=False必 配对 签名 / 白名单 / 幂等。json→jsonrpc已 全量迁移并测试(第三十六章)。- HttpCase / 监控 覆盖 核心路径。
50.10.2 截图占位

50.10.3 本节练习
- 简答:用 不超过五条 话向 产品经理 解释 为何支付回调 不能 只靠
auth='user'。 - 判断:路由层 做 限流 是 多余 的,应 只在 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 19 应type='jsonrpc'或改用type='http'+ 自解析 JSON(第三十六章)。下例 A 用http+ 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.11.5 本节练习
- 简答:为何「手机号 + openid」双因子 仍建议在 服务端 分别向 微信 API 校验,而 不 只信客户端提交的 openid 字符串?
- 实操:在 案例 A 中为
/myapp/mini/v1/login增加 每分钟 IP 限流(应用内计数或 Redis,二选一思路即可)。 - 判断:
auth='none'下使用sudo()创建用户 总是安全 的。( )
参考答案提示:3. 错;sudo 绕过规则,须 业务层证明 code/phone 来源可信。
50.11.6 参考案例 C:手机验证码登录(发送验证码 + 校验登录)
你给的原始代码链路是典型的:
/sms/verifycode生成验证码并发送短信;/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、手机号脱敏值),这样菜单可以直接用于:
- 追查某手机号为什么总是登录失败;
- 识别高频请求并封禁;
- 评估短信渠道稳定性(成功率、耗时、错误码分布)。
本章综合练习
- 实现:
GET /api/v1/library/books/<int:book_id>,type='http',auth='none'+X-API-Key头 校验(与第三十八章风格一致);404 / 200 JSON 结构明确。 - 实现:同资源再提供
jsonrpc版/library/jsonrpc/book_read,params含 id,返回read()字段;对比 两种客户端 调用代码量。 - 安全:列出 本模块所有
csrf=False路由,逐条 写 补偿措施(无则改设计)。 - 迁移:全仓库
grep "type='json'",输出 文件:行号 与 计划替换为jsonrpc的回归用例。 - 综合:画 一张 从浏览器到 Controller 再到 ORM 的 数据流(手绘拍照或 Mermaid 均可),标出
auth、csrf、rule作用点。
与第三十六章的关系:第三十六章侧重 前端 URL、json→jsonrpc 迁移、安全清单;本章侧重 @http.route 契约、路径与 request、http/jsonrpc 对比、排错。建议 两章联读,并在 项目 Wiki 中维护 「路由注册表」(URL、auth、csrf、负责人)。
本章对应白皮书目录:第五十章 HTTP 控制器与 @http.route。