Skip to content

PluginDev

pollybird edited this page Sep 12, 2026 · 3 revisions

插件开发指南

插件开发指南

以 plugins/recruit(招聘管理)为完整实战样板,覆盖从需求到发布的全部环节。该插件可在后台「插件管理」中下载源码对照学习。

对照参考:plugins/banner(最简模板函数型)、plugins/product(完整:栏目归属 + API + 全站搜索提供者)、plugins/recruit(本指南主线:独立路由 + 表单 + 文件上传 + 截止时间门控 + 全站搜索提供者)。

1. 需求拆解

需求 涉及层 实现
岗位数据(标题/部门/地点/截止时间) 数据模型 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)

2. 目录结构与 manifest.json

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。

3. PluginBase 入口(init.py)

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。

4. 数据模型

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 起同样可翻译,留空回退主表默认语言。

5. 后台路由

从核心 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_*,不要自注册新蓝本,否则后台前缀切换时不会即时失效。

6. 前台蓝图与模板函数

模板函数(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)

7. 表单与文件上传

投递申请六步校验链:

  1. 截止时间后端复检(前台不渲染是第一道门)
  2. 图形验证码一次性消费(session.pop('form_captcha'))
  3. 必填项与长度校验
  4. 简历上传:复用 save_upload_file(),限制后缀 ['doc','docx','xls','xlsx','pdf']
  5. 入库(记录来源 IP)
  6. 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

8. 前台导航贡献

实现 get_frontend_menu() 返回 [{'label','url','target'}],核心自动聚合为虚拟外链栏目节点追加到导航树尾。仅启用中插件参与聚合,禁用即时消失;归属栏目的插件(如 product)无需实现本钩子。

9. sitemap 贡献

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'}

10. 全站搜索贡献(v2.5.2)

插件自有的前台公开内容(岗位、产品等)通过 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() 统一生成。

11. REST API 扩展(可选)

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、缓存与统一响应包。

12. 生命周期管理

操作 行为 数据保留
启用 种子权限 → 角色授权 → 建表 → 写清单 复用历史数据
禁用 从清单移除 slug(v2.5.2 起回调 on_disabled() 清理索引) 表与数据保留
卸载 验证码确认后物理删目录 数据表保留
下载 打包 zip(单目录形态) —
上传 8 层校验后解压 —

13. 自测清单

  • 启动无异常,管理页显示已加载
  • 启用后权限点/菜单/表创建成功
  • 后台 CRUD 正常,审计有记录
  • 前台动态 200,伪静态开关两种模式正常
  • 验证码一次性消费,非法文件被拒绝
  • 导航/sitemap/API 启用后出现,禁用后消失
  • 模板函数禁用后返回 fallback 不报错
  • 搜索:保存后可搜到,下架/删除后消失,禁用插件后无残留(v2.5.2)
  • 多语言:翻译留空回退默认语言,前台/搜索/API 三处一致(v2.5.2)
  • 卸载后目录删除,数据表保留,重新上传恢复

14. 常见陷阱

  • 后台模板脚本块是 {% 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)。

Clone this wiki locally