Appearance
服务端控制器架构
本文档详细说明 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) |
fetch | async (templatePath) → reply.send(html) | 渲染模板并发送 HTML 响应 |
view | async (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.fetch → Base.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_compress 为 true 时,输出的 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通过 Cookie
js
request.cookies.signature // 防重放签名
request.cookies.theme // 用户选择的主题模式