Skip to content

服务端控制器架构

本文档详细说明 DJAOD 主题系统的控制器层架构,包括基类设计、请求生命周期、鉴权机制、模板渲染方法等。


控制器文件结构

backend-admin/src/app/index/
├── BaseController.js        # 空基类(保留扩展点)
├── controller/
│   ├── Base.js              # ★ 核心基类(~728 行),所有控制器的真实父类
│   ├── Index.js             # 首页/站点/分享控制器
│   ├── Ajax.js              # 前端通用 Ajax 接口控制器
│   ├── Agent.js             # AI 搜索控制器
│   ├── Album.js             # 专辑控制器
│   ├── Author.js            # 创作者控制器
│   ├── Cart.js              # 购物车控制器
│   ├── Cdkey.js             # 卡密控制器
│   ├── Channel.js           # 频道控制器
│   ├── Circle.js            # 圈子控制器
│   ├── Combination.js       # 团购控制器
│   ├── Comment.js           # 评论控制器
│   ├── Coupon.js            # 优惠券控制器
│   ├── Dynamic.js           # 动态页面控制器
│   ├── Goods.js             # 商品控制器
│   ├── Message.js           # 消息控制器
│   ├── Music.js             # 音乐控制器
│   ├── Notify.js            # 通知控制器
│   ├── Order.js             # 订单控制器
│   ├── Popup.js             # 弹窗控制器
│   ├── Recharge.js          # 充值控制器
│   ├── Rss.js               # RSS/搜索引擎抓取控制器
│   ├── Search.js            # 搜索控制器
│   ├── Seckill.js           # 秒杀控制器
│   ├── Signin.js            # 签到控制器
│   ├── Special.js           # 歌单控制器
│   ├── Third.js             # 第三方登录/支付控制器
│   ├── Type.js              # 分类控制器
│   ├── User.js              # 用户控制器
│   ├── Video.js             # 视频控制器
│   └── Vip.js               # VIP 会员控制器
├── middleware/
│   └── UserAuthTokenValidation.js  # 登录鉴权中间件
└── routes/
    └── index.js             # 自动生成的路由配置

类继承关系

BaseController (空基类,预留)
    └── Base (核心基类,728行,包含所有模板渲染与请求处理逻辑)
            └── Index      (首页控制器)
            └── Ajax       (通用AJAX接口)
            └── Music      (音乐控制器)
            └── Album      (专辑控制器)
            └── User       (用户控制器)
            └── ...         (所有业务控制器)

Base 核心基类方法全集

Base 是所有控制器的真实父类,位于 controller/Base.js

构造函数

js
constructor(fastify) {
    this.fastify = fastify;
    this.loadingMethod = 'static';
    
    // 初始化默认站点配置
    this.site = {
        site_name: '网站',
        site_title: '首页',
        site_keywords: '',
        site_description: '',
        site_status: 1,
        html_compress: true,
        // ... 更多站点配置字段
    };
    
    this.setupErrorHandler();      // 注册全局错误处理器
    this.setupRequestHooks();      // 注册 preHandler 请求钩子
}

模板渲染相关方法

方法签名说明
assign(name, value) → this向模板注入变量(存到 request._viewCtx.data
fetchasync (templatePath) → reply.send(html)渲染模板并发送 HTML 响应
viewasync (templatePath) → [template, data, options]准备渲染所需的模板路径、数据、布局选项
layout(layout) → this设置/切换布局(false 禁用,'default' 默认)
disableLayout() → this禁用布局(等于 layout(false)
enableLayout(layout = 'default') → this启用布局
getLayout() → string获取当前布局名
hasLayout() → boolean判断是否有布局

响应输出方法

方法签名说明
error(message, statusCode = 500)错误响应(JSON 或 HTML 错误页)
success(message, url)操作成功页面(带倒计时跳转)
json(data, statusCode = 200)JSON 响应
redirect(url, statusCode = 302)重定向

内部辅助方法

方法说明
getCurrentRequest()从 AsyncLocalStorage 获取当前请求对象
getCurrentReply()从 AsyncLocalStorage 获取当前响应对象
initConfig()异步加载 webpc_config 配置到 this.site
setDefaultVariables()设置默认 SEO 变量到 request._viewCtx.data
ensureTemplateVariables(data)确保必要模板变量存在(安全检查)
getSafeUser(request)获取安全的用户对象(含默认值兜底)
_refreshUserData(request, force)从数据库刷新用户信息(带 5 分钟缓存)
isLogin()判断当前用户是否已登录
detectThemeType(request)检测设备类型(pc / wap
setupErrorHandler()注册 Fastify 全局错误处理器
setupRequestHooks()注册 Fastify preHandler 请求钩子
compressHtml(html)HTML 压缩(移除换行/注释/多余空格)
getSignature(request, reply)生成/刷新防重放签名 Cookie

请求生命周期详解

一个完整的请求处理流程:

阶段 1:路由匹配

Fastify 根据 URL 匹配 routes/index.js 中的路由配置,找到对应的 Controller 和 handler 方法。

阶段 2:中间件执行

如果路由配置了 preHandler 中间件(如 UserAuthTokenValidation),先执行鉴权:

js
// UserAuthTokenValidation.js
const user = req.session.user;
if (!user) {
    // 未登录:JSON 请求返回 403,普通请求重定向到登录页
    let url = fastify.utils.getUrl("User/login");
    if (isJsonRequest) {
        reply.code(403).send({ code: 403, msg: '请先登录', data: { jump: url } });
    } else {
        return reply.redirect(url + '?redirect=' + req.url);
    }
}

阶段 3:preHandler 钩子 (Base.setupRequestHooks)

js
async (request, reply) => {
    // 3.1 初始化请求上下文
    request._viewCtx = {
        layout: 'default',
        data: {},
        isLayoutLocked: false,
        themeType: this.detectThemeType(request)  // pc / wap
    };
    
    // 3.2 处理 x-theme-layout 头部(允许前端覆盖布局)
    const layoutHeader = request.headers['x-theme-layout'];
    if (layoutHeader === 'off') {
        request._viewCtx.layout = false;
        request._viewCtx.isLayoutLocked = true;
    }
    
    // 3.3 解析 multipart/form-data
    if (request.method === 'POST' && request.isMultipart()) {
        // 将 multipart 数据扁平化为普通对象
    }
    
    // 3.4 加载站点配置
    await this.initConfig();
    
    // 3.5 设置默认 SEO 变量
    this.setDefaultVariables();
    
    // 3.6 缓存用户数据
    if (request.session?.user) {
        request._userCache = {
            data: request.session.user,
            cachedAt: Date.now(),
            stale: false
        };
    }
    
    // 3.7 生成/刷新 signature Cookie
    await this.getSignature(request, reply);
    
    // 3.8 检查站点是否关闭
    if (this.site.site_status == 0) {
        return this.error("网站维护中,请稍后访问");
    }
    
    // 3.9 检查 Redis 用户缓存刷新标记
    if (userId) {
        request.redis_user_cache = await this.fastify.redis.get('refresh_user_cache_key:' + userId);
        if (request.redis_user_cache === 'refresh') {
            this._refreshUserData(request, true);
        }
    }
}

阶段 4:控制器方法执行

业务控制器(如 Music.detail)被调用:

js
async detail(request, reply) {
    // 使用 fastify.getModel() 获取 Mongoose 模型
    const songModel = this.fastify.getModel("music_song");
    const song = await songModel.findById(request.params.id);
    
    if (!song) {
        return this.error('歌曲不存在', 404);
    }
    
    // 注入模板变量
    this.assign('obj', song);
    this.assign('title', song.title + ' - ' + this.site.site_name);
    
    // 渲染模板
    return this.fetch('music/detail');
}

阶段 5:模板渲染 (Base.fetchBase.view)

js
async fetch(templatePath) {
    const request = this.getCurrentRequest();
    const reply = this.getCurrentReply();
    
    // 5.1 调用 view() 准备渲染参数
    const options = await this.view(templatePath);
    
    // 5.2 异步渲染 EJS 模板
    let html = await reply.viewAsync(...options);
    
    // 5.3 可选:HTML 压缩
    if (this.site.html_compress) {
        html = this.compressHtml(html);
    }
    
    // 5.4 发送 HTML 响应
    return reply.type('text/html; charset=utf-8').send(html);
}

Base.view() 的核心逻辑:

js
async view(templatePath) {
    const ctx = request._viewCtx;
    
    // 获取主题名
    const themeConfig = await this.fastify.getConfig(['pc_theme', 'wap_theme', 'mob_status']);
    let theme = themeConfig.pc_theme || 'default_pc';
    if (themeConfig.mob_status == 2) {
        theme = themeConfig[`${ctx.themeType}_theme`] || theme;
    }
    
    // 构建模板文件路径
    const template = `${theme}/view/${templatePath}.ejs`;
    
    // 构建布局路径
    let layoutPath = null;
    if (this.hasLayout()) {
        layoutPath = `${theme}/view/layout/${this.getLayout()}.ejs`;
    }
    
    // 读取装修数据
    const themeDesign = await this.fastify.getTemplateDesign(theme);
    
    // 组装数据对象
    const data = {
        ...ctx.data,                     // 控制器通过 assign() 注入的数据
        __TPL_STATIC__: `/${theme}/`,    // 主题静态资源路径
        currentTheme: theme,
        themeType: ctx.themeType,
        design: themeDesign,             // 装修布局数据
        layout: this.getLayout(),
        user: ctx.data.user || this.getSafeUser(request),
        cookie: ctx.data.cookie || request.cookies || {},
        request: request,
        site: this.site,
        isWechat: ...,
        isMobile: ctx.themeType === 'wap',
        isAjax: request.headers['x-requested-with'] === 'XMLHttpRequest',
        timestamp: new Date().toISOString()
    };
    
    // 注入主题工具函数
    if (this.fastify.templateUtils?.[theme]) {
        Object.assign(data, this.fastify.templateUtils[theme]);
    }
    
    // 注入页面模型
    if (this.page_model) {
        data.page_model = this.page_model;
    }
    
    // 返回渲染参数: [模板路径, 数据, 布局选项]
    return [template, data, { layout: layoutPath }];
}

阶段 6:HTML 压缩(可选)

html_compresstrue 时,输出的 HTML 会被压缩:

js
compressHtml(html) {
    // 保护 data-title 属性中的空格
    html = html.replace(/data-title="([^"]*)"/g, (match, content) => {
        return `data-title="${content.replace(/ /g, '%%SPACE%%')}"`;
    });
    
    html = html
        .replace(/\r\n|\n|\t/g, '')      // 移除换行和制表符
        .replace(/>\s*([^ ]*)\s*</g, '>$1<')  // 标签间空格
        .replace(/\s+/g, ' ')             // 多个空格变一个
        .replace(/<!--[\w\W\r\n]*?-->/g, '')   // 删除 HTML 注释
        .replace(/ \"/g, '"')             // 修复属性前空格
        .replace(/\/\*[^*]*\*\//g, '');   // 删除 CSS/JS 注释
    
    // 恢复 data-title 中的原始空格
    html = html.replace(/%%SPACE%%/g, ' ');
    return html;
}

鉴权机制

免登录声明 (noNeedLogin)

每个控制器可以声明不需要登录的方法列表:

js
// controller/Index.js
export default class Index extends Base {
    noNeedLogin = ["index", "link", "sitemap", "trackVisit"];
    
    // index() 和 link() 和 sitemap() 和 trackVisit() 无需登录即可访问
    // 其他方法需要登录
}

// controller/Ajax.js
export default class Ajax extends Base {
    noNeedLogin = ["index", "audioplayer", "sendEmail", "sendSms", "share", "captcha"];
}

免权限声明 (noNeedRight)

声明不需要额外权限检查的方法:

js
noNeedRight = [];

中间件配置

routes/index.js 中,每个路由可以配置 preHandler 中间件:

json
{
    "path": "/author/apply",
    "handler": "apply",
    "method": ["GET", "POST"],
    "preHandler": ["UserAuthTokenValidation"]
}

UserAuthTokenValidation 中间件检查 req.session.user,未登录时:

  • JSON 请求:返回 { code: 403, msg: '请先登录', data: { jump: '/user/login' } }
  • 普通请求:重定向到 /user/login?redirect=<当前URL>

控制器方法规范

标准渲染方法

js
async methodName(request, reply) {
    // 1. 设置页面元数据
    this.assign('title', '页面标题 - ' + this.site.site_name);
    this.assign('keywords', '关键词');
    this.assign('description', '描述');
    
    // 2. 查询业务数据
    const data = await this.fastify.getModel("model_name").findById(request.params.id);
    
    // 3. 注入数据到模板
    this.assign('obj', data);
    
    // 4. 渲染模板 (对应 view/<controller>/<method>.ejs)
    return this.fetch('controller_name/method_name');
}

错误处理

js
// 数据不存在
if (!data) {
    return this.error('数据不存在', 404);
}

// 参数验证
if (!type || !id) {
    return this.error('参数不正确');
}

// 权限检查
if (data.user_id !== user._id) {
    return this.error('无权操作', 403);
}

AJAX 接口规范

js
// 返回 JSON 数据
async someAjaxMethod(request, reply) {
    const { type, id } = request.body;
    
    // 参数白名单校验
    if (!['music', 'album', 'video'].includes(type)) {
        return reply.send({ code: 400, msg: '无效的类型' });
    }
    
    // 业务逻辑 ...
    
    return reply.send({
        code: 200,
        msg: 'ok',
        data: result
    });
}

操作成功跳转

js
// 带倒计时的成功页面
return this.success('操作成功', '/user/index');

// 直接 JSON 返回
return this.json({ result: 'ok' });

// 直接重定向
return this.redirect('/user/index');

全局错误处理器

Base.setupErrorHandler() 注册的全局错误处理器:

js
this.fastify.setErrorHandler((error, request, reply) => {
    const theme = 'default_pc';
    const contentType = request.headers['content-type'] || '';
    
    // JSON 请求直接返回 JSON 错误
    if (contentType.includes('application/json') || 
        contentType.includes('multipart/form-data')) {
        return reply.status(error.statusCode || 500).send({
            code: error.statusCode || 500,
            message: error.message
        });
    }
    
    // 普通请求渲染错误页
    const isProd = process.env.APP_DEBUG !== 'true';
    const errorMsg = isProd ? error.message : error.stack;
    
    const data = {
        error: { message: errorMsg, statusCode: error.statusCode },
        url: request.raw.url,
        site: site,
        title: (error.statusCode === 404 ? '页面不存在' : '服务器错误') + ' - ' + site.site_name,
        __TPL_STATIC__: `/${theme}/`,
        request, user, cookie, isWechat
    };
    
    if (error.statusCode === 404) {
        return reply.status(404).view(theme + '/view/error/404.ejs', data);
    }
    return reply.status(error.statusCode || 500).view(theme + '/view/error/index.ejs', data);
});

这意味着 404 和 500 错误都会自动渲染对应模板,开发者在主题中需要:

  • 创建 view/error/404.ejs — 处理 404 页面
  • 创建 view/error/index.ejs — 处理通用错误页面

AsyncLocalStorage 上下文传递

系统使用 Node.js 的 AsyncLocalStorage 在异步调用链中安全传递 request / reply

js
// requestStore.js 提供
const { request, reply } = requestStore();

// Base 中获取
getCurrentRequest() {
    const { request } = requestStore();
    return request;
}

getCurrentReply() {
    const { reply } = requestStore();
    return reply;
}

控制器方法不需要手动传递 request / reply,通过 getCurrentRequest() / getCurrentReply() 即可获取当前上下文。


开发新控制器

完整示例

js
// controller/Blog.js
import Base from './Base.js';

export default class Blog extends Base {
    constructor(fastify) {
        super(fastify);
    }

    // 免登录方法
    noNeedLogin = ["list", "detail"];
    noNeedRight = [];

    async list(request, reply) {
        this.assign('title', '博客列表 - ' + this.site.site_name);
        this.assign('keywords', '博客,文章');
        this.assign('description', '最新博客文章');

        const { page = 1 } = request.query;
        const { list, page: pageInfo } = await this.fastify.getModel("blog_post")
            .getList({ status: 1 }, page, 20, 'createtime', -1);

        this.assign('list', list);
        this.assign('page', pageInfo);

        return this.fetch('blog/list');
    }

    async detail(request, reply) {
        const post = await this.fastify.getModel("blog_post")
            .findById(request.params.id);

        if (!post) {
            return this.error('文章不存在', 404);
        }

        this.assign('obj', post);
        this.assign('title', post.title + ' - ' + this.site.site_name);

        return this.fetch('blog/detail');
    }

    async create(request, reply) {
        // 需要登录才能访问(不在 noNeedLogin 中)
        this.layout('editor');
        return this.fetch('blog/create');
    }

    async save(request, reply) {
        const { title, content } = request.body;
        const post = await this.fastify.getModel("blog_post").create({
            title, content,
            user_id: request.session.user._id,
            status: 1
        });
        return this.success('发布成功', `/blog/${post._id}`);
    }
}

在路由中注册

将新建控制器的路由配置追加到 routes/index.js 中:

json
{
    "path": "/blog",
    "routes": [
        { "path": "/", "handler": "list", "method": ["GET"] },
        { "path": "/:id", "handler": "detail", "method": ["GET"] },
        { "path": "/create", "handler": "create", "method": ["GET"], 
          "preHandler": ["UserAuthTokenValidation"] },
        { "path": "/save", "handler": "save", "method": ["POST"],
          "preHandler": ["UserAuthTokenValidation"] }
    ],
    "controller": "Blog"
}

控制器间的数据共享

通过 Fastify 实例

js
// 在 Base 构造函数中
this.fastify.appServices.utility       // 通用服务
this.fastify.getModel("model_name")    // Mongoose 模型
this.fastify.getConfig("config_key")   // 系统配置
this.fastify.redis                     // Redis 实例

通过 session

js
request.session.user                   // 当前用户信息
request.session.user._id              // 用户 ID
js
request.cookies.signature              // 防重放签名
request.cookies.theme                  // 用户选择的主题模式

Released under the MIT License.