Repository navigation
PluginDev
以 plugins/recruit(招聘管理)为完整实战样板,覆盖从需求到发布的全部环节。该插件可在后台「插件管理」中下载源码对照学习。
对照参考:
plugins/banner(最简模板函数型)、plugins/product(完整:栏目归属 + API + 全站搜索提供者)、plugins/recruit(本指南主线:独立路由 + 表单 + 文件上传 + 截止时间门控 + 全站搜索提供者)。
| 需求 | 涉及层 | 实现 |
|---|---|---|
| 岗位数据(标题/部门/地点/截止时间) | 数据模型 |
RecruitJob,启用自动建表 |
| 求职申请(基本信息 + 简历) | 数据模型 | RecruitApplication |
| 后台岗位/申请管理 | 后台路由 + 模板 |
admin.py 挂核心 admin_bp
|
| 截止时间门控 | 模型方法 + 双重校验 |
is_open() / is_expired()
|
| 简历上传(word/excel/pdf) | 复用核心上传 |
save_upload_file() + 白名单 |
| 前台列表/详情/投递 | 前台蓝图 |
/jobs、/job-<id>.html
|
| 前台导航入口 | 菜单钩子 | get_frontend_menu() |
| 权限/审计/sitemap | 声明式注册 |
permissions / audit_modules / get_sitemap_urls()
|
| 岗位进入全站搜索(v2.5.2) | 搜索提供者 |
get_search_provider() → SearchProvider(search.py) |
plugins/recruit/
├── manifest.json # 元数据(builtin=false 可卸载)
├── __init__.py # PluginBase 子类 + plugin 实例
├── models.py # RecruitJob / RecruitApplication / RecruitJobTranslation
├── admin.py # 后台路由(挂核心 admin_bp)
├── frontend.py # 模板函数 recruit_jobs() / recruit_job_url()
├── frontend_routes.py # 前台蓝图
├── search.py # 全站搜索提供者 SearchProvider(v2.5.2)
├── translations/ # 插件独立翻译域(en)
└── templates/recruit/
├── index.html # 后台岗位列表
├── form.html # 后台岗位表单
├── applications.html # 后台申请列表
├── jobs.html # 前台岗位列表
└── job_detail.html # 前台详情 + 申请表单
manifest.json:
{
"slug": "recruit",
"name": "招聘管理",
"version": "1.1.0",
"description": "招聘岗位发布与在线求职申请...",
"author": "ZhyCMS 社区示例",
"builtin": false,
"min_core_version": "2.6.4",
"requires": [],
"extends": ""
}v2.6.4 起,manifest.json 支持三个依赖相关字段(均可选):
-
requires(string[]):启用前必须已启用的依赖插件 slug 列表。 -
extends(string):父插件 slug,本插件基于其二次开发,父插件须已安装且已启用。 -
min_core_version(string):最低核心 CMS 版本,低于此值拒绝启用。
启用时系统按 最低核心版本 → requires → extends 顺序强校验,不满足则拒绝启用;禁用被依赖的插件也会被拦截。详见项目根目录 PLUGIN_DEPENDENCIES.md。
from app.plugin_api import PluginBase
from . import admin as _admin # noqa: F401 导入即注册后台路由
from .frontend import recruit_job_url, recruit_jobs
class RecruitPlugin(PluginBase):
slug = 'recruit'
name = '招聘管理'
version = '1.1.0'
permissions = [('recruit:manage', '招聘管理', '招聘岗位维护与求职申请处理')]
preset_role_grants = {
'content_editor': ['recruit:manage'],
'content_auditor': ['recruit:manage'],
}
audit_modules = [('recruit', '招聘管理')]
def get_admin_menu(self):
return [
{'label': '招聘岗位', 'endpoint': 'admin.recruit_job_index',
'icon': 'fa-briefcase', 'permission': 'recruit:manage'},
{'label': '求职申请', 'endpoint': 'admin.recruit_application_index',
'icon': 'fa-user-graduate', 'permission': 'recruit:manage'},
]
def get_frontend_blueprint(self):
from .frontend_routes import recruit_frontend
return recruit_frontend
def get_jinja_globals(self):
return {'recruit_job_url': recruit_job_url, 'recruit_jobs': recruit_jobs}
def get_jinja_fallbacks(self):
return {'recruit_job_url': '#', 'recruit_jobs': []}
def get_frontend_menu(self):
return [{'label': '招贤纳士', 'url': '/jobs', 'target': ''}]
def get_sitemap_urls(self):
# 返回岗位详情页 URL 列表
...
def get_search_provider(self):
# v2.5.2:岗位进入全站搜索(索引/SQL 兜底/结果 URL)
from .search import RecruitSearchProvider
return RecruitSearchProvider()
def on_disabled(self):
# 禁用时把历史索引中的岗位文档全部移除,避免搜索命中 404 页
...
plugin = RecruitPlugin() # 必须!核心通过模块级 plugin 变量识别导入顺序陷阱:
from . import admin必须在顶部完成,保证模型在db.create_all()之前注册进 metadata。
class RecruitJob(db.Model):
__tablename__ = 'recruit_jobs'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
department = db.Column(db.String(100))
location = db.Column(db.String(200))
headcount = db.Column(db.Integer, default=1)
salary = db.Column(db.String(100))
description = db.Column(db.Text) # 富文本
deadline = db.Column(db.DateTime, index=True)
is_enabled = db.Column(db.Boolean, default=True)
is_deleted = db.Column(db.Boolean, default=False)
sort_order = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
def is_expired(self):
if self.deadline is None:
return False
return datetime.now() > self.deadline
def is_open(self):
return self.is_enabled and not self.is_deleted and not self.is_expired()
class RecruitJobTranslation(db.Model):
"""岗位多语言翻译(v2.5.0 基础字段;v2.5.2 增加结构化字段)。
主表存默认语言,翻译表存非默认语言;查不到翻译或字段为空时
fallback 主表(前台用全局 t() / 后端 t_field 自动处理)。
"""
__tablename__ = 'recruit_job_translations'
id = db.Column(db.Integer, primary_key=True)
job_id = db.Column(db.Integer, db.ForeignKey('recruit_jobs.id', ondelete='CASCADE'),
nullable=False, index=True)
locale = db.Column(db.String(10), nullable=False)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
department = db.Column(db.String(100)) # v2.5.2 部门翻译
location = db.Column(db.String(200)) # v2.5.2 工作地点翻译
salary = db.Column(db.String(100)) # v2.5.2 薪资待遇翻译
class RecruitApplication(db.Model):
__tablename__ = 'recruit_applications'
STATUS_PENDING = 'pending'
STATUS_VIEWED = 'viewed'
STATUS_PASS = 'pass'
STATUS_REJECT = 'reject'
id = db.Column(db.Integer, primary_key=True)
job_id = db.Column(db.Integer, db.ForeignKey('recruit_jobs.id'), nullable=False)
name = db.Column(db.String(64), nullable=False)
phone = db.Column(db.String(32), nullable=False)
email = db.Column(db.String(128))
resume_file = db.Column(db.String(500)) # 相对路径
resume_name = db.Column(db.String(255)) # 原始文件名
status = db.Column(db.String(16), default=STATUS_PENDING)
created_at = db.Column(db.DateTime, default=datetime.now)
job = db.relationship('RecruitJob', backref=db.backref('applications', lazy='dynamic'))💡 业务语义收口在模型层(
is_open()/is_expired()),前后台共用,避免视图里散落时间比较。结构化字段(部门/地点/薪资)v2.5.2 起同样可翻译,留空回退主表默认语言。
从核心 admin_bp 注册,每个视图套三层守卫:插件启用 → 权限点 → 业务校验。
def _gate(view):
@wraps(view)
def wrapper(*args, **kwargs):
if not plugin_enabled('recruit'):
abort(404)
return view(*args, **kwargs)
return wrapper
@admin_bp.route('/recruit/jobs')
@_gate
@permission_required('recruit:manage')
def recruit_job_index():
...
⚠️ 后台路由 endpoint 用admin.recruit_*,不要自注册新蓝本,否则后台前缀切换时不会即时失效。
模板函数(frontend.py):
def recruit_job_url(job):
if Setting.get('seo_rewrite_enable') == 'on':
return f'/job-{job.id}.html'
return url_for('recruit_frontend.job_detail', jid=job.id)
def recruit_jobs(limit=10, only_open=False):
q = RecruitJob.query.filter_by(is_deleted=False, is_enabled=True)
if only_open:
q = q.filter((RecruitJob.deadline.is_(None))
| (RecruitJob.deadline > datetime.now()))
jobs = q.order_by(RecruitJob.sort_order.desc(),
RecruitJob.id.desc()).limit(limit).all()
from app.utils.i18n_content import t_field
# v2.5.2:结构化字段同样经 t_field 按当前语言取翻译,空回退默认语言
return [{'id': j.id, 'title': j.title, 'url': recruit_job_url(j),
'department': t_field(j, 'department') or '',
'location': t_field(j, 'location') or '',
'salary': t_field(j, 'salary') or ''} for j in jobs]
⚠️ 模板中生成插件对象链接时用注册的 URL 全局函数(recruit_job_url(j)),不要写{{ j.url }}——模型没有该属性时 Jinja 静默输出空 href。
前台蓝图(frontend_routes.py)三个关键机制:
# 机制 1:启用守卫
@recruit_frontend.route('/jobs')
@_gate
def job_list(): ...
# 机制 2:主题覆盖 → 插件自带兜底
def _resolve_template(name):
theme = get_active_theme()
if os.path.isfile(os.path.join(THEMES_DIR, theme, name)):
return theme_template(name)
return f'recruit/{name.replace("recruit_", "")}'
# 机制 3:伪静态路由
@recruit_frontend.route('/job-<int:jid>.html')
@_gate
def job_detail_rewrite(jid):
if Setting.get('seo_rewrite_enable') != 'on':
abort(404)
return job_detail(jid)投递申请六步校验链:
- 截止时间后端复检(前台不渲染是第一道门)
- 图形验证码一次性消费(
session.pop('form_captcha')) - 必填项与长度校验
- 简历上传:复用
save_upload_file(),限制后缀['doc','docx','xls','xlsx','pdf'] - 入库(记录来源 IP)
- PRG 重定向(
?submitted=1防重复提交)
简历安全下载(三重防护):
def resume_abs_path(app_row):
rel = app_row.resume_file.replace('\\', '/')
if not rel.startswith('uploads/'):
return None
rel = rel[len('uploads')+1:]
ext = rel.rsplit('.', 1)[1].lower()
if ext not in RESUME_EXTS:
return None
joined = safe_join(current_app.config['UPLOAD_FOLDER'], rel)
return joined if joined and os.path.isfile(joined) else None实现 get_frontend_menu() 返回 [{'label','url','target'}],核心自动聚合为虚拟外链栏目节点追加到导航树尾。仅启用中插件参与聚合,禁用即时消失;归属栏目的插件(如 product)无需实现本钩子。
def get_sitemap_urls(self):
base = request.url_root.rstrip('/') if has_request_context() else Setting.get('site_url', '')
for j in RecruitJob.query.filter_by(is_enabled=True, is_deleted=False).limit(2000):
yield {'loc': base + recruit_job_url(j),
'lastmod': j.updated_at.strftime('%Y-%m-%d'),
'changefreq': 'monthly', 'priority': '0.6'}插件自有的前台公开内容(岗位、产品等)通过 get_search_provider() 提供一个 SearchProvider 实例,即可进入全站搜索——无需插件自己维护任何索引。核心统一负责:重建索引写入各语言、保存时实时索引(reindex_object('recruit_job', id))、索引零命中/故障时 SQL 兜底、结果按类型生成详情 URL、插件禁用时清理索引残留。
search.py 的完整骨架(节选自 recruit,产品插件同构):
from app.utils.search import SearchProvider
class RecruitSearchProvider(SearchProvider):
type = 'recruit_job' # 全局唯一的内容类型标识,作为索引 uid 前缀
def iter_docs(self, locale):
"""后台「重建索引」时逐条产出该语言下的可见文档。"""
for j in RecruitJob.query.filter_by(is_enabled=True, is_deleted=False):
doc = self._doc(j, locale)
if doc: yield doc
def get_doc(self, obj_id, locale):
"""单条文档(保存时实时索引);返回 None 表示下架/删除 → 移出索引。"""
j = RecruitJob.query.filter_by(id=obj_id, is_enabled=True,
is_deleted=False).first()
return self._doc(j, locale) if j else None
def sql_search(self, keyword, locale, page=1, per_page=20):
"""索引故障/未建时的 SQL LIKE 兜底,返回 (items, total)。
非默认语言同时匹配翻译表(含 v2.5.2 的 department/location/salary)。
"""
...
def build_url(self, item):
"""搜索结果 → 详情页链接(伪静态开关两种模式)。"""
if Setting.get('seo_rewrite_enable') == 'on':
return f'/job-{item["id"]}.html'
return url_for('recruit_frontend.job_detail', jid=item['id'])文档 dict 字段:
id, title, summary, content(可含 HTML,核心自动去标签), column_id, column_name, column_slug, published_at。保存/删除/批量上下架时在 admin.py 里调reindex_object()/unindex_object()即完成同步;结果项的url由核心视图调用build_url()统一生成。
def get_api_routes(self, api_bp):
@api_bp.route('/recruit/jobs')
@api_cache('recruit_job')
def list_jobs():
if not plugin_enabled('recruit'):
return api_err(404, '资源不存在')
rows = RecruitJob.query.filter_by(is_enabled=True, is_deleted=False).all()
return api_ok([{'id': r.id, 'title': r.title} for r in rows])挂在核心
api_bp自动享受 Token 鉴权、CORS、缓存与统一响应包。
| 操作 | 行为 | 数据保留 |
|---|---|---|
| 启用 | 种子权限 → 角色授权 → 建表 → 写清单 | 复用历史数据 |
| 禁用 | 从清单移除 slug(v2.5.2 起回调 on_disabled() 清理索引) |
表与数据保留 |
| 卸载 | 验证码确认后物理删目录 | 数据表保留 |
| 下载 | 打包 zip(单目录形态) | — |
| 上传 | 8 层校验后解压 | — |
- 启动无异常,管理页显示已加载
- 启用后权限点/菜单/表创建成功
- 后台 CRUD 正常,审计有记录
- 前台动态 200,伪静态开关两种模式正常
- 验证码一次性消费,非法文件被拒绝
- 导航/sitemap/API 启用后出现,禁用后消失
- 模板函数禁用后返回 fallback 不报错
- 搜索:保存后可搜到,下架/删除后消失,禁用插件后无残留(v2.5.2)
- 多语言:翻译留空回退默认语言,前台/搜索/API 三处一致(v2.5.2)
- 卸载后目录删除,数据表保留,重新上传恢复
- 后台模板脚本块是
{% block js %}(不是scripts),父模板不存在的块名会被静默丢弃。 -
__init__.py末尾必须plugin = RecruitPlugin()。 - 模型 import 必须在
db.create_all()之前完成。 - 前台交互页(含验证码/flash)不要接页面缓存。
- 字典用
g['items']而非g.items(后者命中 dict 方法)。 - 伪静态路由加入回归测试,确保不劫持核心
/article-<id>.html。 - SearchProvider 的
iter_docs/get_doc必须自己过滤下架/删除内容——核心靠get_doc返回None判断移出索引,过滤漏了下架内容仍会被搜到(v2.5.2)。