跳至主要内容

附錄 -- HydroOJ 整體架構流程圖

HydroOJ 整體架構流程圖

注:下圖為 HydroOJ 整體架構流程圖,參考自 官方repo 非官方之圖式,有誤請不吝告知我們進行修正,謝謝!

HydroOJ 套件資料夾結構

my-addon/
├── package.json
├── frontend?/
├── \[a-zA-Z0-9_\]+.page.tsx?
├── locales?/
├── ko.yml?
├── zh_TW.yml?
├── en.yml?
├── zh.yml?
├── public?/
├── templates?/
├── <>.html?
├── index.ts

?的資料夾或檔案表示是選用的不是必須的

  • package.json:套件的設定檔案 (預設就會有)
  • frontend/:注射前端頁面檔案 (選用)
    • \[a-zA-Z0-9_\]+.page.tsx:注射前端頁面檔案 (選用)
  • locales/:多國語系檔案 (選用)
  • public/:靜態資源檔案 (選用)
  • templates/:路由渲染前端模板檔案 (選用)
  • index.ts:套件主要程式碼 (預設就會有)

Handler 相關附錄

*路由名即該頁面的data-page屬性(可在瀏覽器開發者工具(按F12)中查看 會在最外層的<html>標籤中)

假設有一個路由是ctx.Route('contest_create', '/contest/create', ContestEditHandler)

  • 路由名為contest_create
  • Handler 名稱為ContestEditHandler

如何查詢 Handler 名稱

可以在 HydroOJ repo (或你要查的addon)中搜尋路由定義ctx.Route() 你會看到ctx.Route('problem_list', '/p', ProblemListHandler);之類的程式碼 來查看各個路由所對應的 Handler 名稱 ProblemListHandler 就是該路由(/p)所對應的 Handler 名稱 注:一個路由只會對應到一個 Handler 名稱 但一個 Handler 名稱可能會對應到多個路由(像是編輯頁面和查看頁面可能會共用同一個 Handler 名稱)

常用 Handler 名稱對照表

  1. 首頁與用戶相關

    Handler 名稱路由路徑功能常見擴展用途
    HomeHandler/首頁添加輪播圖、公告、統計信息
    UserDetailHandler/user/:uid用戶詳情頁添加 Rating、徽章、自定義資料
    UserLoginHandler/login登錄頁添加第三方登錄、驗證邏輯
    UserRegisterHandler/register註冊頁自定義註冊流程、邀請碼
    HomeSecurityHandler/home/security安全設置添加兩步驗證、設備管理
    HomeSettingsHandler/home/settings/:category個人設置添加自定義設置項
  2. 題目相關

    Handler 名稱路由路徑功能常見擴展用途
    ProblemListHandler/p題目列表添加篩選、標籤、難度分級
    ProblemDetailHandler/p/:pid題目詳情添加題解、討論、統計數據
    ProblemEditHandler/p/:pid/edit編輯題目添加批量操作、模板
    ProblemCreateHandler/p/create創建題目添加題目導入功能
    ProblemSubmitHandler/p/:pid/submit提交代碼添加代碼模板、預處理
    ProblemSolutionHandler/p/:pid/solution題解列表添加點贊、評論
    ProblemManageHandler/p/:pid/manage題目管理添加批量測試數據操作
  3. 記錄與評測相關

    Handler 名稱路由路徑功能常見擴展用途
    RecordListHandler/record提交記錄列表添加統計、篩選器
    RecordDetailHandler/record/:rid提交記錄詳情添加代碼高亮、性能分析
    JudgeConnectionHandler/judge/conn評測機連接自定義評測邏輯
    JudgeFilesDownloadHandler/judge/files評測文件下載自定義文件處理
  4. 比賽相關

    Handler 名稱路由路徑功能常見擴展用途
    ContestListHandler/contest比賽列表添加分類、報名狀態
    ContestDetailHandler/contest/:tid比賽詳情添加比賽公告、賽前提醒
    ContestScoreboardHandler/contest/:tid/scoreboard排行榜添加 Rating 變化、封榜
    ContestEditHandler/contest/:tid/edit編輯比賽添加模板、批量導入
    ContestCodeHandler/contest/:tid/code查看代碼添加代碼對比、查重
  5. 作業相關

    Handler 名稱路由路徑功能常見擴展用途
    HomeworkMainHandler/homework作業列表添加進度追蹤
    HomeworkDetailHandler/homework/:tid作業詳情添加提交狀態、截止提醒
    HomeworkScoreboardHandler/homework/:tid/scoreboard作業排行榜添加完成度統計
  6. 討論相關

    Handler 名稱路由路徑功能常見擴展用途
    DiscussionMainHandler/discuss討論列表添加分類、熱門話題
    DiscussionDetailHandler/discuss/:did討論詳情添加點贊、回覆樹
    DiscussionEditHandler/discuss/:did/edit編輯討論添加富文本編輯器
    DiscussionReplyHandler/discuss/:did/reply回覆討論添加 @ 提醒、表情
  7. 域管理相關

    Handler 名稱路由路徑功能常見擴展用途
    DomainDetailHandler/domain/:domainId域詳情添加域統計、公告
    DomainEditHandler/domain/:domainId/edit編輯域添加自定義配置項
    DomainUserHandler/domain/:domainId/user域用戶管理添加批量操作、角色管理
    DomainRankHandler/domain/:domainId/rank域排行榜添加多維度排名
  8. 訓練相關

    Handler 名稱路由路徑功能常見擴展用途
    TrainingListHandler/training訓練計劃列表添加推薦算法
    TrainingDetailHandler/training/:tid訓練計劃詳情添加進度可視化
  9. 系統管理相關

    Handler 名稱路由路徑功能常見擴展用途
    SystemMainHandler/manage系統管理主頁添加儀表板組件
    SystemDashboardHandler/manage/dashboard系統儀表板添加監控圖表
    SystemSettingHandler/manage/setting系統設置添加自定義設置項
    SystemUserImportHandler/manage/userimport用戶導入添加批量導入格式

Handler 實例的詳細結構

interface Handler {
// ===== 用戶相關 =====
user: User; // 當前登錄用戶

// ===== 域相關 =====
domain: DomainDoc; // 當前域文檔

// ===== 請求相關 =====
request: {
path: string; // 請求路徑,如 /p/1000
method: string; // HTTP 方法,如 GET, POST
params: Record<string, any>; // URL 參數,如 { pid: '1000' }
query: Record<string, any>; // 查詢參數,如 { page: '1' }
body: Record<string, any>; // POST 數據
ip: string; // 客戶端 IP
headers: Record<string, string>; // HTTP 頭
cookies: Record<string, string>; // Cookies
files?: Record<string, any>; // 上傳的文件
json?: boolean; // 是否 JSON 請求
};

// ===== 響應相關 =====
response: {
body: any; // 響應數據(傳給模板)
template?: string; // 模板文件名
type?: string; // Content-Type
status?: number; // HTTP 狀態碼
redirect?: string; // 重定向 URL
disposition?: string; // Content-Disposition
etag?: string; // ETag
headers?: Record<string, string>; // 自定義響應頭
};

// ===== 會話相關 =====
session: {
uid: number; // 用戶 ID
viewLang?: string; // 界面語言
sudo?: number; // sudo 模式時間戳
[key: string]: any; // 其他會話數據
};

// ===== 解析後的參數 =====
args: Record<string, any>; // 通過 @param 裝飾器解析的參數

// ===== 上下文 =====
ctx: Context; // 插件上下文
}

特定 Handler 的額外屬性(範例)

// ProblemDetailHandler
interface ProblemDetailHandler extends Handler {
pdoc: ProblemDoc; // 題目文檔
udoc?: User; // 題目作者信息
psdoc?: any; // 題目狀態
}

// ContestDetailHandler
interface ContestDetailHandler extends Handler {
tdoc: Tdoc; // 比賽文檔
tsdoc?: any; // 比賽狀態
}

// RecordDetailHandler
interface RecordDetailHandler extends Handler {
rdoc: RecordDoc; // 記錄文檔
pdoc?: ProblemDoc; // 關聯的題目
udoc?: User; // 提交用戶
}

// UserDetailHandler
interface UserDetailHandler extends Handler {
udoc: User; // 用戶文檔
}

Handler 生命週期流程圖

渲染機制流程圖

template 模板系統相關附錄

模板引擎使用 Nunjucks

// 來源: packages/ui-default/backendlib/template.ts
export class TemplateService extends Service {
constructor(ctx: Context) {
super(ctx, 'template');

// 設置 Nunjucks 環境
const env = new Nunjucks(Loader);

// 註冊過濾器
env.addFilter('json', JSON.stringify);
env.addFilter('translate', (str) => this.translate(str));

// 註冊全局變量
env.addGlobal('UiContext', UiContext);
env.addGlobal('formatDate', formatDate);
}

// 渲染模板
render(name: string, context: any) {
return this.env.render(name, context);
}
}

模板上下文

// Handler 渲染時自動注入的上下文
const templateContext = {
// Handler 相關
handler: this, // Handler 實例
user: this.user, // 當前用戶
domain: this.domain, // 當前域

// 請求相關
request: this.request, // 請求對象
args: this.args, // URL 參數

// 響應數據
...this.response.body, // 響應體數據

// UI 上下文
UiContext: this.UiContext, // UI 配置

// 輔助函數
url: this.url.bind(this), // URL 生成
_: this.translate.bind(this), // 翻譯函數
formatDate: (date) => ..., // 日期格式化
avatar: (hash) => ..., // 頭像 URL
};

Service 服務相關附錄

如何查詢 Service 服務名稱

可以在 HydroOJ repo (或你要查的addon)中搜尋服務註冊代碼範例

// 示例 1: MongoService
// 來源: packages/hydrooj/src/service/db.ts
export class MongoService extends Service {
constructor(ctx: Context, private config: MongoConfig = {}) {
super(ctx, 'db'); // ← 註冊為 'db'
// ^^^^ 這就是服務名稱
}
}

這樣就可以知道服務註冊名稱db對應的服務類型MongoService

常用 Service 名稱對照表

服務註冊名稱服務類型/實例 (TypeScript 類型)功能描述
dbMongoServiceMongoDB 數據庫操作服務
serverWebServiceHTTP 服務
settingSettingService系統設置服務
loaderLoader加載器服務
workerWorkerService工作線程服務
i18nI18nService國際化服務
oauthOauthModelOAuth 認證模型
apiApiServiceAPI 服務
checkCheckService檢查服務
templateTemplateService模板服務
scoreboardScoreboardService排行榜服務

事件相關附錄

事件系統架構圖

所以只要查ctx.emit就可以找到事件名字了

事件類型

  1. Handler 生命週期事件
  • handler/before/<HandlerClassName>:在指定的處理器執行前觸發
  • handler/after/<HandlerClassName>:在指定的處理器執行後觸發
  • handler/before/<routeName>:在指定的路徑載入前觸發
  • handler/after/<routeName>:在指定的路徑載入後觸發
  1. 應用生命週期事件
  • app/started:應用啟動完成

Service 服務相關附錄

HydroOJ 提供許多內置服務,可以通過 ctx.inject() 進行依賴注入。

常用服務列表

服務名稱說明使用方式
dbMongoDB 數據庫連接ctx.db.collection(...)
storage文件存儲服務await storage.put(path, file)
bus事件總線ctx.on(eventName, handler)
problemModel題目模型await ProblemModel.get(...)
userModel用戶模型await UserModel.getById(...)
documentModel文檔模型await DocumentModel.add(...)
domainModel域模型await DomainModel.getById(...)
mail郵件服務await mail.sendMail(...)
server伺服器配置server.config

依賴注入範例

export async function apply(ctx: Context) {
// 等待 db 和 storage 服務就緒
ctx.inject(['db', 'storage'], (c) => {
// c.db 和 c.storage 現在已就緒
const db = c.db;
const storage = c.storage;

// 可以設置定時任務
const timer = setInterval(async () => {
const docs = await db.collection('items').find({}).toArray();
console.log('Found', docs.length, 'items');
}, 60000);

// 返回清理函數(插件卸載時會調用)
return () => {
clearInterval(timer);
console.log('Cleanup done');
};
});
}

前端 API 完整參考

注:這些 API 需要在 frontend/*.page.tsx 中導入使用。

頁面系統

import { Page, NamedPage, AutoloadPage, addPage } from '@hydrooj/ui-default';

// 在所有頁面運行
addPage(new AutoloadPage('MyAutoload', () => {
console.log('Running on every page');
}));

// 在特定頁面運行
addPage(new NamedPage('problem_detail', () => {
console.log('Running on problem detail page');
}));

// 在多個頁面運行
addPage(new NamedPage(['problem_detail', 'problem_main'], () => {
console.log('Running on problem pages');
}));

對話框與通知

import { 
Dialog,
ActionDialog,
InfoDialog,
ConfirmDialog,
alert,
confirm,
prompt,
Notification
} from '@hydrooj/ui-default';

// 簡單提示
alert('This is an alert');
confirm('Are you sure?');
prompt('Please enter:', 'default value');

// 通知
Notification.success('Success!');
Notification.error('Error!');
Notification.warning('Warning!');
Notification.info('Info!');

// 高級對話框
new InfoDialog({
title: 'Information',
message: 'This is information'
}).show();

HTTP 請求

import { request } from '@hydrooj/ui-default';

// GET
const res = await request.get('/api/data');
console.log(res.data);

// POST
const res = await request.post('/api/data', { key: 'value' });

// PUT、DELETE、PATCH
await request.put('/api/data', { key: 'value' });
await request.delete('/api/data');
await request.patch('/api/data', { key: 'value' });

// 錯誤處理
try {
const res = await request.get('/api/data');
} catch (error) {
console.error('Request failed:', error.message);
}

多國語系

import { i18n } from '@hydrooj/ui-default';

// 獲取翻譯文本
const text = i18n('key');
console.log(text);

// 替換模板
import { substitute } from '@hydrooj/ui-default';
const result = substitute('Hello, {0}!', ['World']);
// 結果: 'Hello, World!'

工具函數

import {
delay,
secureRandomString,
getTheme,
downloadFile,
uploadFiles,
pjax,
tpl,
rawHtml,
base64,
mongoId,
zIndexManager,
getAvailableLangs,
loadReactRedux
} from '@hydrooj/ui-default';

// 延遲
await delay(1000); // 延遲 1 秒

// 生成隨機字符串
const token = secureRandomString(32);

// 獲取主題
const theme = getTheme(); // 'light' 或 'dark'

// 下載文件
downloadFile('/api/download', 'filename.txt');

// 上傳文件
const files = await uploadFiles();

// PJAX 導航(無整頁刷新)
pjax('/new/path');

// HTML 模板
const html = tpl`<div>${variable}</div>`;

// 原始 HTML(避免轉義)
const html = rawHtml('<strong>Bold</strong>');

// Base64 編碼/解碼
const encoded = base64.encode('hello');
const decoded = base64.decode(encoded);

// 生成 MongoDB ObjectId
const id = mongoId();

// 獲取可用的語言
const langs = getAvailableLangs(); // ['zh_TW', 'en', ...]

常見錯誤與解決方案

1. 模塊找不到

錯誤: Cannot find module '@hydrooj/ui-default'

解決: 確保在 package.json 中正確安裝了依賴

{
"peerDependencies": {
"hydrooj": "*"
}
}

2. Handler 方法未執行

錯誤: Handler 的方法沒有被調用

可能原因:

  • 路由名稱拼寫錯誤
  • Handler 類繼承錯誤
  • 權限檢查失敗

解決:

// 檢查路由註冊
ctx.Route('correct_name', '/path', MyHandler);

// 檢查 Handler 繼承
class MyHandler extends Handler {
async get() {
// 實現邏輯
}
}

3. 模板變量未渲染

錯誤: {{ variable }} 在模板中顯示為空

可能原因:

  • 傳入的數據缺少該字段
  • 變量名拼寫不正確
  • 模板繼承錯誤

解決:

class MyHandler extends Handler {
async get() {
this.response.template = 'my_template.html';
this.response.body = {
variable: 'Value' // 確保名稱與模板中的變量匹配
};
}
}

4. UI 注射不顯示

錯誤: UI 注射的元素沒有出現在頁面上

可能原因:

  • 注射點名稱錯誤
  • 路由名稱不存在
  • 權限檢查未通過

解決:

// 確認注射點名稱
ctx.injectUI('Nav', 'correct_route', {
prefix: 'my_addon'
});

// 檢查路由是否存在
ctx.Route('my_addon_page', '/my/page', MyHandler);

// 檢查權限設置
import { PRIV } from 'hydrooj';
ctx.injectUI('UserDropdown', 'route', {}, PRIV.PRIV_USER_PROFILE);

最佳實踐建議

  1. 命名規範

    • 路由名以插件名開頭,並用下劃線分隔
    • 集合名以 插件名. 開頭
    • i18n key 使用小寫並用下劃線分隔
  2. 型別安全

    • 總是為集合定義 TypeScript 接口
    • 使用 @param 裝飾器驗證輸入
  3. 錯誤處理

    • 捕捉所有 async 操作的異常
    • 使用適當的 HTTP 狀態碼
  4. 性能優化

    • 為常用查詢建立索引
    • 使用分頁避免一次載入過多數據
    • 緩存不常變化的數據
  5. 代碼組織

    • 將 Handler 類分離到不同文件
    • 使用模型層封裝數據庫邏輯
    • 提取公共邏輯到工具函數

更新日誌

章節結構

  • 第 0 章:介紹(基礎概念)
  • 第 1 章:安裝與設定
  • 第 2 章:前端基礎
  • 第 3 章:多國語系與模板
  • 第 4 章:服務基礎
  • 第 5 章:Context 物件
  • 第 6 章:Handler 基礎
  • 第 7 章:UI 注射(新增)
  • 第 8 章:數據庫與模型(新增)
  • 第 9 章:Handler 進階(新增)
  • 附錄:架構與參考

最近更新

  • 新增 UI 注射完整指南(第 7 章)
  • 新增數據庫與模型部分(第 8 章)
  • 新增 Handler 進階用法(第 9 章)
  • 擴展前端 API 文檔(第 2 章)
  • 增加模板系統複雜用法(第 3 章)
  • 新增常見錯誤與解決方案
  • 新增最佳實踐建議
草貓
第十四屆進階教學 aka.架網站的那個