# Hydro API Docs — Full Reference > Hydro 在线评测系统插件开发 API 文档(完整版)。 > Deployed: https://hydro-plugin-api-reference.pages.dev > Source: https://github.com/hydro-dev/Hydro --- ## 核心 ### Context 与 Service > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/context.ts ```ts import { Context, Service, Fiber, FiberState } from 'hydrooj' ``` # Context 与 Service 构成 Hydro 可扩展性基础的核心插件系统类。基类来自 `cordis`。 ```ts import { Context, Service, Fiber, FiberState } from 'hydrooj'; ``` ## Context 每个插件可访问的核心对象。提供事件订阅、服务注入、路由注册和生命周期管理功能。 `Context` 继承自 `cordis.Context`,通过 `ApiMixin` 服务添加了 Hydro 特有的方法。`WebService` 还混入了 `Route`、`Connection` 和 `withHandlerClass`。 ### 路由方法 由 `WebService` 混入,用于注册 HTTP 和 WebSocket 处理器。 | 方法 | 说明 | |------|------| | `Route(name, path, Handler, ...permPrivChecker)` | 注册 HTTP 路由;绑定 `name`、`path` 和 `Handler` 类,可附加权限/特权守卫。 | | `Connection(name, path, Handler, ...permPrivChecker)` | 注册 WebSocket 连接端点;签名与 `Route` 相同,但用于持久连接。 | ### 事件方法 继承自 `cordis.Context`。Hydro 定义了自己的 `EventMap`(参见 `Events`),包含领域相关事件。 | 方法 | 说明 | |------|------| | `on(event, callback)` | 订阅事件;返回一个 `Disposable`,调用即可移除监听器。 | | `emit(event, ...args)` | 同步触发事件,通知所有已注册的监听器。 | | `parallel(event, ...args)` | 触发事件并并发运行所有监听器;返回 `Promise`,在所有监听器完成后 resolve。 | | `broadcast(event, ...args)` | 跨所有集群进程广播事件(PM2 或 MongoDB 总线);在每个节点上调用 `parallel`。 | ### 生命周期与插件方法 继承自 `cordis.Context`,用于管理插件和副作用。 | 方法 | 说明 | |------|------| | `plugin(Plugin, config?)` | 在当前上下文注册并初始化插件(类或函数);返回 `Fiber`。 | | `effect(() => Disposable)` | 注册副作用;返回的清理函数在上下文销毁时调用。 | | `mixin(serviceId, methods)` | 将服务实例上的命名方法混入上下文原型,使其可通过 `ctx.method()` 调用。 | | `inject` | 在插件/服务类上声明服务依赖(静态 `inject` 数组)。 | ### Hydro 特有方法 由 `ApiMixin` 添加 —— 可作为每个上下文实例的直接方法调用。 | 方法 | 说明 | |------|------| | `addScript(name, description, schema, run)` | 注册一个具名管理脚本,包含输入验证和异步执行函数。 | | `provideModule(type, id, module)` | 注册一个可插拔模块(如 `'hash'`、`'problemSearch'`)到全局模块注册表。 | | `injectUI(node, name, args?, ...permPrivChecker)` | 将 UI 组件注入到前端插槽(`Nav`、`ProblemAdd`、`ControlPanel` 等),可附加权限守卫。 | | `setImmediate(callback)` | 在下一个事件循环 tick 调度回调;上下文销毁时自动清理。 | ### Context 属性 | 属性 | 类型 | 说明 | |------|------|------| | `loader` | `Loader` | 管理插件生命周期和热重载的插件加载器服务。 | | `check` | `CheckService` | 用于健康监测的检查/ping 服务。 | | `domain` | `DomainDoc?` | 当前域文档(在域作用域的上下文中可用)。 | | `geoip` | `GeoIP?` | GeoIP 解析服务(可选,可能未加载)。 | --- ## Service 所有 Hydro 服务的抽象基类。继承 `cordis.Service`。 ```ts import { Service } from 'hydrooj'; export default class MyService extends Service { static inject = ['database']; // 声明依赖 constructor(ctx: Context) { super(ctx, 'myService'); } } ``` 服务通过 `ctx.plugin(MyService)` 注册,以其服务 ID 在上下文中可用。其他插件通过静态 `inject` 数组声明依赖。 --- ## 类型 `Fiber` 和 `FiberState` 从 `hydrooj` 重新导出(源自 `cordis`,适配 Hydro 的 `Context`)。`Disposable` 和 `Plugin` 为 `cordis` 内部类型,供参考。 | 类型 | 说明 | |------|------| | `Fiber` | `cordis.Fiber` —— 表示插件在上下文树中的生命周期节点;追踪状态(pending、loading、active、disposed)。 | | `FiberState` | Fiber 生命周期状态枚举:`PENDING`、`LOADING`、`ACTIVE`、`DISPOSED` 等。 | | `Disposable` | `() => void` —— 由 `on()`、`effect()` 等注册方法返回的清理函数(`cordis` 类型,需从 `cordis` 直接导入)。 | | `Plugin` | 插件定义的类型别名 —— 可以是继承 `Service` 的类或 `(ctx, config) => void` 函数(`cordis` 类型,需从 `cordis` 直接导入)。 | ### 错误类 (Error Classes) > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/error.ts # 错误类 (Error Classes) Hydro 插件开发中可用的所有错误类型,包括基础错误类和 Hydro 自定义业务错误。 > **Source**: `packages/hydrooj/src/error.ts`, `@hydrooj/framework/error` ## 导入 ```ts import { HydroError, UserFacingError, BadRequestError, ForbiddenError, NotFoundError, CreateError, // ... 自定义错误类 } from 'hydrooj'; ``` ## CreateError 工厂函数 `CreateError` 用于创建自定义错误类。所有 Hydro 内置错误均通过此函数生成。 ### 签名 ```ts function CreateError( name: string, Base: typeof UserFacingError, message: string | ((this: HydroError) => string), httpStatus?: number ): typeof UserFacingError; ``` ### 参数 | 参数 | 类型 | 说明 | |------|------|------| | `name` | `string` | 错误类名称,同时作为错误 `name` 属性 | | `Base` | `typeof UserFacingError` | 父错误类,决定 HTTP 状态码默认值 | | `message` | `string \| (this: HydroError) => string` | 错误消息模板,支持 `{0}` `{1}` `{2}` 占位符;或返回消息的函数 | | `httpStatus` | `number` | 可选,覆盖 HTTP 状态码 | ### 用法示例 ```ts import { CreateError, ForbiddenError } from 'hydrooj'; // 简单消息 + 占位符 export const MyError = CreateError('MyError', ForbiddenError, 'Something went wrong: {0}'); // 动态消息(基于 this.params 计算) export const DynamicError = CreateError('DynamicError', ForbiddenError, function (this: HydroError) { return `Value is ${this.params[0]}`; }); // 抛出 throw new MyError('detail info'); ``` 消息模板中的 `{0}`、`{1}`、`{2}` 会被替换为构造函数传入的参数(`this.params`)。 --- ## 基础错误类 由 `@hydrooj/framework/error` 导出,构成错误继承体系。 | 类名 | HTTP 状态码 | 说明 | |------|------------|------| | `HydroError` | — | 所有 Hydro 错误的基类。持有 `name`、`params`、`message` 属性 | | `UserFacingError` | 500 | 面向用户的错误基类,继承 `HydroError`。带 HTTP 状态码 | | `BadRequestError` | 400 | 请求参数或业务逻辑无效,继承 `UserFacingError` | | `ForbiddenError` | 403 | 权限不足或操作被拒绝,继承 `UserFacingError` | | `NotFoundError` | 404 | 资源不存在,继承 `UserFacingError` | 继承关系: ``` HydroError └── UserFacingError (500) ├── BadRequestError (400) ├── ForbiddenError (403) └── NotFoundError (404) ``` --- ## Hydro 自定义错误 以下错误均定义于 `packages/hydrooj/src/error.ts`,通过 `CreateError` 创建。 ### Internal / Server Error (500) 继承 `UserFacingError`,表示服务端异常。 | 错误类 | 默认消息 | |--------|----------| | `RemoteOnlineJudgeError` | `RemoteOnlineJudgeError` | | `SendMailError` | `Failed to send mail to {0}. (1)` | ### Permission / Auth Errors (403) 继承 `ForbiddenError`,表示权限验证失败。 | 错误类 | 默认消息 | |--------|----------| | `LoginError` | `Invalid password for user {0}.` | | `BuiltinLoginError` | `Builtin login is disabled.` | | `AccessDeniedError` | `Access denied.` | | `InvalidTokenError` | `The {0} Token is invalid.` | | `BlacklistedError` | `Address or user {0} is blacklisted.` | | `VerifyPasswordError` | `Passwords don't match.` | | `OpcountExceededError` | `Too frequent operations of {0} (limit: {2} operations in {1} seconds).` | | `PermissionError` | `You don't have the required permission ({0}) in this domain.` *动态* | | `PrivilegeError` | `You don't have the required privilege.` *动态* | | `CurrentPasswordError` | `Current password doesn't match.` | > `PermissionError`:若 `params[0]` 为 `bigint` 权限标志,自动替换为对应的权限描述文本。 > > `PrivilegeError`:若缺少 `PRIV_USER_PROFILE`,消息变为 `"You're not logged in."`。 ### User / Domain Errors (403) 继承 `ForbiddenError`,表示用户或域操作冲突。 | 错误类 | 默认消息 | |--------|----------| | `UserAlreadyExistError` | `User {0} already exists.` | | `RoleAlreadyExistError` | `This role already exists.` | | `DomainAlreadyExistsError` | `The domain {0} already exists.` | | `DomainJoinForbiddenError` | `You are not allowed to join domain {0}. {1}` | | `DomainJoinAlreadyMemberError` | `Failed to join the domain. You are already a member.` | | `InvalidJoinInvitationCodeError` | `The invitation code you provided is invalid.` | | `AlreadyVotedError` | `You've already voted.` | ### Contest / Homework Errors (403) 继承 `ForbiddenError`,表示比赛/作业相关业务规则违反。 | 错误类 | 默认消息 | |--------|----------| | `ContestNotAttendedError` | `You haven't attended this contest yet.` | | `ContestAlreadyAttendedError` | `You've already attended this contest.` | | `ContestNotLiveError` | `This contest is not live.` | | `ContestNotEndedError` | `This contest is not ended.` | | `ContestScoreboardHiddenError` | `Contest scoreboard is not visible.` | | `HomeworkNotLiveError` | `This homework is not open.` | | `HomeworkNotAttendedError` | `You haven't claimed this homework yet.` | ### Training Errors (403) 继承 `ForbiddenError`。 | 错误类 | 默认消息 | |--------|----------| | `TrainingAlreadyEnrollError` | `You've already enrolled this training.` | ### Problem / File Errors (403) 继承 `ForbiddenError`,表示题目或文件操作被拒绝。 | 错误类 | 默认消息 | |--------|----------| | `NotAssignedError` | `You are not assigned to this {0}.` | | `FileLimitExceededError` | `File {0} limit exceeded.` | | `FileUploadError` | `File upload failed.` | | `FileExistsError` | `File {0} already exists.` | | `HackFailedError` | `Hack failed: {0}` | | `ProblemAlreadyExistError` | `Problem {0} already exists.` | | `ProblemAlreadyUsedByContestError` | `Problem {0} is already used by contest {1}.` | | `ProblemNotAllowPretestError` | `Pretesting is not supported for {0}.` | | `ProblemNotAllowLanguageError` | `This language is not allowed to submit.` | 注意:虽然 JS 变量名为 `ProblemNotAllowLanguageError`,但通过 `CreateError` 注册的内部错误名(`.name` 属性)为 `ProblemNotAllowSubmitError` | | `ProblemNotAllowCopyError` | `You are not allowed to copy this problem from {0} to {1}.` | | `DiscussionLockedError` | `The discussion is locked, you can not reply anymore.` | | `RequireProError` | `RequireProError` | ### Validation / Logic Errors (400) 继承 `BadRequestError`,表示请求参数或业务逻辑无效。 | 错误类 | 默认消息 | |--------|----------| | `PretestRejudgeFailedError` | `Cannot rejudge a pretest record.` | | `HackRejudgeFailedError` | `Cannot rejudge a hack record.` | | `CannotDeleteSystemDomainError` | `You are not allowed to delete system domain.` | | `OnlyOwnerCanDeleteDomainError` | `You are not the owner of this domain.` | | `CannotEditSuperAdminError` | `You are not allowed to edit super admin in web.` | | `ProblemConfigError` | `Invalid problem config.` | | `ProblemIsReferencedError` | `Cannot {0} of a referenced problem.` | | `AuthOperationError` | `{0} is already {1}.` | | `NotLaunchedByPM2Error` | `Not launched by PM2.` | ### Not Found Errors (404) 继承 `NotFoundError`,表示资源不存在。 | 错误类 | 默认消息 | |--------|----------| | `UserNotFoundError` | `User {0} not found.` | | `NoProblemError` | `No problem.` | | `RecordNotFoundError` | `Record {0} not found.` | | `ProblemDataNotFoundError` | `Data of problem {0} not found.` | | `MessageNotFoundError` | `Message {0} not found.` | | `DocumentNotFoundError` | `Document {2} not found.` | ### Document Sub-type Not Found (404) 继承 `DocumentNotFoundError`(间接继承 `NotFoundError`),表示特定类型的文档不存在。 | 错误类 | 默认消息 | |--------|----------| | `ProblemNotFoundError` | `Problem {1} not found.` | | `SolutionNotFoundError` | `Solution {1} not found.` | | `TrainingNotFoundError` | `Training {1} not found.` | | `ContestNotFoundError` | `Contest {1} not found.` | | `DiscussionNotFoundError` | `Discussion {1} not found.` | | `DiscussionNodeNotFoundError` | `Discussion node {1} not found.` | 继承关系: ``` NotFoundError └── DocumentNotFoundError ("Document {2} not found.") ├── ProblemNotFoundError ("Problem {1} not found.") ├── SolutionNotFoundError ("Solution {1} not found.") ├── TrainingNotFoundError ("Training {1} not found.") ├── ContestNotFoundError ("Contest {1} not found.") ├── DiscussionNotFoundError ("Discussion {1} not found.") └── DiscussionNodeNotFoundError ("Discussion node {1} not found.") ``` ### EventMap (事件总线) > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/service/bus.ts # EventMap (事件总线) Hydro 插件系统的事件总线。所有可监听事件定义在 `EventMap` 接口中,通过 `cordis` 框架的事件机制分发。 > **Source**: `packages/hydrooj/src/service/bus.ts` > > ```ts > import { Events } from 'hydrooj'; // re-exported as Events > import type { EventMap } from 'hydrooj'; // original type name > ``` --- ## 使用模式 ### ctx.on() — 订阅事件 注册事件监听器,返回一个 `Disposable` 函数,调用即可取消订阅。 ```ts const dispose = ctx.on('problem/add', (doc, docId) => { console.log('New problem added:', docId); }); // 取消订阅 dispose(); ``` ### ctx.emit() — 同步触发 同步触发事件,所有监听器按注册顺序执行。 ```ts ctx.emit('record/change', rdoc, $set, $push, body); ``` ### ctx.parallel() — 并发触发 触发事件,所有监听器并发执行,返回 `Promise` 在全部完成后 resolve。 ```ts await ctx.parallel('app/ready'); ``` ### ctx.broadcast() — 跨进程广播 跨集群广播事件(PM2 或 MongoDB 总线)。每个节点收到后以 `parallel` 方式执行。 ```ts ctx.broadcast('record/judge', rdoc, updated, pdoc, updater); ``` --- ## App Lifecycle | Event | Signature | Description | |-------|-----------|-------------| | `app/listen` | `() => void` | HTTP 服务器开始监听端口时触发。 | | `app/started` | `() => void` | 应用启动完成时触发。 | | `app/ready` | `() => VoidReturn` | 所有插件加载完毕、应用就绪时触发。用于异步初始化。 | | `app/exit` | `() => VoidReturn` | 应用即将关闭时触发。用于清理资源。 | | `app/before-reload` | `(entries: Set) => VoidReturn` | 热重载发生前触发,`entries` 为即将重载的插件路径集合。 | | `app/reload` | `(entries: Set) => VoidReturn` | 热重载完成后触发。 | ## File Watch | Event | Signature | Description | |-------|-----------|-------------| | `app/watch/change` | `(path: string) => VoidReturn` | 监视的文件内容发生变更时触发。 | | `app/watch/unlink` | `(path: string) => VoidReturn` | 监视的文件被删除时触发。 | ## Database | Event | Signature | Description | |-------|-----------|-------------| | `database/connect` | `(db: Db) => void` | 数据库连接建立后触发,传入 `Db` 实例。 | | `database/config` | `() => VoidReturn` | 数据库配置加载时触发。 | ## System | Event | Signature | Description | |-------|-----------|-------------| | `system/setting` | `(args: Record) => VoidReturn` | 系统设置变更时触发。 | | `system/setting-loaded` | `() => VoidReturn` | 系统设置加载完毕时触发。 | | `bus/broadcast` | `(event: keyof EventMap, payload: any, trace?: string) => VoidReturn` | 跨进程广播消息到达时触发。一般不直接监听,由 `ctx.broadcast()` 内部使用。 | | `monitor/update` | `(type: 'server' \| 'judge', $set: any) => VoidReturn` | 监控数据更新时触发。 | | `monitor/collect` | `(info: any) => VoidReturn` | 收集监控信息时触发。 | | `api/update` | `() => void` | API 路由更新时触发。 | ## Scheduled Task | Event | Signature | Description | |-------|-----------|-------------| | `task/daily` | `() => void` | 每日定时任务触发。 | | `task/daily/finish` | `(pref: Record) => void` | 每日任务完成后触发,传入执行偏好统计。 | ## Subscription (WebSocket) | Event | Signature | Description | |-------|-----------|-------------| | `subscription/init` | `(h: ConnectionHandler, privileged: boolean) => VoidReturn` | WebSocket 订阅初始化时触发。 | | `subscription/subscribe` | `(channel: string, user: User, metadata: Record) => VoidReturn` | 用户订阅某频道时触发。 | | `subscription/enable` | `(channel: string, h: ConnectionHandler, privileged: boolean, onDispose: (disposable: () => void) => void) => VoidReturn` | 订阅频道激活时触发;通过 `onDispose` 注册清理回调。 | ## User | Event | Signature | Description | |-------|-----------|-------------| | `user/message` | `(uid: number[], mdoc: Omit) => void` | 用户收到消息时触发。 | | `user/get` | `(udoc: User) => void` | 用户信息被查询时触发。 | | `user/delcache` | `(content: string \| true) => void` | 用户缓存失效时触发。 | | `user/import/parse` | `(payload: any) => VoidReturn` | 用户导入解析阶段触发。 | | `user/import/create` | `(uid: number, udoc: any) => VoidReturn` | 用户导入创建阶段触发。 | ## Domain | Event | Signature | Description | |-------|-----------|-------------| | `domain/create` | `(ddoc: DomainDoc) => VoidReturn` | 域创建后触发。 | | `domain/before-get` | `(query: Filter) => VoidReturn` | 域查询前触发,可修改查询条件。 | | `domain/get` | `(ddoc: DomainDoc) => VoidReturn` | 域查询后触发。 | | `domain/before-update` | `(domainId: string, $set: Partial) => VoidReturn` | 域更新前触发,可拦截或修改更新内容。 | | `domain/update` | `(domainId: string, $set: Partial, ddoc: DomainDoc) => VoidReturn` | 域更新后触发。 | | `domain/delete` | `(domainId: string) => VoidReturn` | 域删除后触发。 | | `domain/delete-cache` | `(domainId: string) => VoidReturn` | 域缓存失效时触发。 | ## Document | Event | Signature | Description | |-------|-----------|-------------| | `document/add` | `(doc: any) => VoidReturn` | 文档创建后触发。 | | `document/set` | `(domainId: string, docType: T, docId: DocType[T], $set: any, $unset: OnlyFieldsOfType) => VoidReturn` | 文档更新时触发。泛型参数 `T` 对应文档类型键。 | ## Discussion | Event | Signature | Description | |-------|-----------|-------------| | `discussion/before-add` | `(payload: Partial) => VoidReturn` | 讨论创建前触发,可修改内容。 | | `discussion/add` | `(payload: Partial) => VoidReturn` | 讨论创建后触发。 | ## Problem | Event | Signature | Description | |-------|-----------|-------------| | `problem/before-add` | `(domainId: string, content: string, owner: number, docId: number, doc: Partial) => VoidReturn` | 题目创建前触发。 | | `problem/add` | `(doc: Partial, docId: number) => VoidReturn` | 题目创建后触发。 | | `problem/before-edit` | `(doc: Partial, $unset: OnlyFieldsOfType) => VoidReturn` | 题目编辑前触发。 | | `problem/edit` | `(doc: ProblemDoc) => VoidReturn` | 题目编辑后触发。 | | `problem/before-del` | `(domainId: string, docId: number) => VoidReturn` | 题目删除前触发。 | | `problem/list` | `(query: Filter, handler: any, sort?: string[]) => VoidReturn` | 题目列表查询时触发。 | | `problem/get` | `(doc: ProblemDoc, handler: any) => VoidReturn` | 题目详情查询后触发。 | | `problem/delete` | `(domainId: string, docId: number) => VoidReturn` | 题目删除后触发。 | | `problem/addTestdata` | `(domainId: string, docId: number, name: string, payload: Omit) => VoidReturn` | 测试数据文件添加后触发。 | | `problem/renameTestdata` | `(domainId: string, docId: number, name: string, newName: string) => VoidReturn` | 测试数据文件重命名后触发。 | | `problem/delTestdata` | `(domainId: string, docId: number, name: string[]) => VoidReturn` | 测试数据文件删除后触发。 | | `problem/addAdditionalFile` | `(domainId: string, docId: number, name: string, payload: Omit) => VoidReturn` | 附加文件添加后触发。 | | `problem/renameAdditionalFile` | `(domainId: string, docId: number, name: string, newName: string) => VoidReturn` | 附加文件重命名后触发。 | | `problem/delAdditionalFile` | `(domainId: string, docId: number, name: string[]) => VoidReturn` | 附加文件删除后触发。 | ## Contest | Event | Signature | Description | |-------|-----------|-------------| | `contest/before-add` | `(payload: Partial) => VoidReturn` | 比赛创建前触发。 | | `contest/add` | `(payload: Partial, id: ObjectId) => VoidReturn` | 比赛创建后触发。 | | `contest/before-edit` | `(tdoc: Tdoc, $set: Partial) => VoidReturn` | 比赛编辑前触发。 | | `contest/edit` | `(payload: Tdoc) => VoidReturn` | 比赛编辑后触发。 | | `contest/list` | `(query: Filter, handler: any) => VoidReturn` | 比赛列表查询时触发。 | | `contest/scoreboard` | `(tdoc: Tdoc, rows: ScoreboardRow[], udict: BaseUserDict, pdict: ProblemDict) => VoidReturn` | 比赛排行榜计算时触发,可修改排名结果。 | | `contest/balloon` | `(domainId: string, tid: ObjectId, bdoc: ContestBalloonDoc) => VoidReturn` | 比赛气球(AC 提示)事件触发。 | | `contest/del` | `(domainId: string, tid: ObjectId) => VoidReturn` | 比赛删除后触发。 | ## Training | Event | Signature | Description | |-------|-----------|-------------| | `training/list` | `(query: Filter, handler: any) => VoidReturn` | 训练计划列表查询时触发。 | | `training/get` | `(tdoc: TrainingDoc, handler: any) => VoidReturn` | 训练计划详情查询后触发。 | ## Record | Event | Signature | Description | |-------|-----------|-------------| | `record/change` | `(rdoc: RecordDoc, $set?: any, $push?: any, body?: any) => void` | 评测记录状态变更时同步触发。 | | `record/judge` | `(rdoc: RecordDoc, updated: boolean, pdoc?: ProblemDoc, updater?: any) => VoidReturn` | 评测记录进入判题阶段时并发触发。 | ## OpLog | Event | Signature | Description | |-------|-----------|-------------| | `oplog/log` | `(type: string, handler: Handler \| ConnectionHandler, args: any, data: any) => VoidReturn` | 操作日志记录时触发。 | --- ## 内部广播机制 Hydro 使用两种跨进程广播实现(`bus.ts` 中的 `apply` 函数): 1. **PM2 模式** — 集群部署时,通过 PM2 的 `launchBus` + BSON 序列化在进程间传递事件。 2. **进程内直调模式** — 单进程或无 PM2 时,`bus/broadcast` 事件直接在本进程内调用 `app.parallel(event, ...payload)`。 插件开发者无需关心底层实现,统一使用 `ctx.broadcast()` 即可。 --- ## 框架 ### @hydrooj/framework 装饰器与验证器 > Source: https://github.com/hydro-dev/Hydro/blob/master/framework/framework/decorators.ts # @hydrooj/framework 装饰器与验证器 用于路由处理方法的参数绑定装饰器和类型验证器。 ```ts import { param, query, post, route, Types } from 'hydrooj'; ``` ## 参数装饰器 将处理方法的参数绑定到特定请求源的装饰器。它们在将值传递给方法之前进行提取、验证和转换。 | 装饰器 | 来源 | 说明 | |--------|------|------| | `param` | 全部(query + body) | 从合并后的请求参数中绑定参数。 | | `query` | 查询字符串 | 从 `request.query` 绑定参数(GET 参数)。 | | `get` | 查询字符串 | `query` 的别名。 | | `post` | 请求体 | 从 `request.body` 绑定参数(POST body)。 | | `route` | 路由参数 | 从路由路径参数及 `domainId` 绑定参数。 | | `subscribe` | — | 将方法(或类)注册为指定频道名的 WebSocket 订阅处理器。 | ### 用法 每个参数装饰器接受 `(name: string, type?: Type, ...options)`: ```ts class MyHandler extends Handler { @param('name', Types.ShortString) @param('page', Types.PositiveInt, true) // 可选 @param('tags', Types.CommaSeperatedArray) async run(name: string, page: number | undefined, tags: string[]) { } } ``` ### 签名 ```ts (name: string, type: Type, validate?: Validator | null, convert?: Converter) => MethodDecorator (name: string, type?: Type, isOptional?: boolean, validate?: Validator, convert?: Converter) => MethodDecorator ``` `Type` 可以是 Schemastery schema 或元组 `[convert, validate?, isOptional?]`。 ## 类型验证器 — `Types` 预置的类型定义,组合了转换器和验证器。每个都是 `Type`,可用作任何参数装饰器的第二个参数。 ### 字符串类型 | 名称 | 输出 | 说明 | |------|------|------| | `Types.Content` | `string` | 多行文本内容(去除首尾空格,最长 65535 字符)。 | | `Types.Key` | `string` | 标识符键(`/^[\w-]{1,255}$/`,SASLprep)。 | | `Types.Name` | `string` | 通用名称字段(1–255 字符,SASLprep)。**@deprecated** | | `Types.Username` | `string` | 用户名(3–31 字符或 2 个 CJK 字符,SASLprep)。 | | `Types.Password` | `string` | 密码字符串(6–255 字符)。 | | `Types.UidOrName` | `string` | 用户 ID(数字)或用户名(3–31 字符或 2 个 CJK 字符,SASLprep)。 | | `Types.Email` | `string` | 邮箱地址(`user@domain.tld`,SASLprep)。 | | `Types.Filename` | `string` | 文件名(1–255 字符,不含 `\/?#~!|*`,SASLprep)。 | | `Types.DomainId` | `string` | 域标识符(字母开头,4–32 个 `\w` 字符,SASLprep)。 | | `Types.ProblemId` | `string \| number` | 题目 ID —— 数字字符串自动转换为 `number`。 | | `Types.Role` | `string` | 角色名(1–31 个 `\w` 或 CJK 字符,SASLprep)。 | | `Types.Title` | `string` | 短标题(1–64 字符,去除首尾空格)。 | | `Types.ShortString` | `string` | 短字符串(1–255 字符)。 | | `Types.String` | `string` | 任意非空字符串。 | | `Types.Emoji` | `string` | 单个 emoji 字符。 | ### 数值类型 | 名称 | 输出 | 说明 | |------|------|------| | `Types.Int` | `number` | 有符号整数(从字符串转换)。 | | `Types.UnsignedInt` | `number` | 非负整数(允许零)。 | | `Types.PositiveInt` | `number` | 正整数(≥ 1)。 | | `Types.Float` | `number` | 有限浮点数(从字符串转换)。 | ### 特殊类型 | 名称 | 输出 | 说明 | |------|------|------| | `Types.ObjectId` | `ObjectId` | MongoDB ObjectId(通过 `ObjectId.isValid` 验证)。 | | `Types.Boolean` | `boolean` | 布尔值 —— 除 `'false'`/`'off'`/`'no'`/`'0'` 外的值为真;始终可选。 | | `Types.Date` | `string` | `YYYY-MM-DD` 格式的日期字符串(零填充)。 | | `Types.Time` | `string` | `HH:MM` 格式的时间字符串(零填充)。 | ### 组合类型 | 名称 | 输出 | 说明 | |------|------|------| | `Types.Range(arr \| obj)` | `T` | 接受给定数组或对象键中的任意值;数字字符串自动转换为 `number`。 | | `Types.NumericArray` | `number[]` | 有限数字数组(逗号分隔字符串或 JSON 数组)。 | | `Types.CommaSeperatedArray` | `string[]` | 以逗号分隔的字符串数组。 | | `Types.Set` | `Set` | 将数组或单个值转换为 `Set`。 | | `Types.Any` | `any` | 透传,不做验证。 | | `Types.ArrayOf(type, isOptional?)` | `T[]` | 将任意 `Type` 包装为数组变体;可选元素变为 `undefined`。 | | `Types.AnyOf(...types)` | `T` | 联合类型 —— 接受匹配给定类型中任意一个的值。 | ## 工具类型 重新导出的 TypeScript 类型,用于构建自定义验证器: | 类型 | 说明 | |------|------| | `Converter` | `(value: any) => T` —— 将原始输入转换为类型化输出。 | | `Validator` | `(value: any) => boolean` —— 值有效时返回 `true`。 | | `Type` | `Schema \| readonly [Converter, Validator?, (boolean \| 'convert')?]` —— Schemastery schema 或 `[convert, validate?, optional?]` 元组。 | ### @hydrooj/framework 重新导出 > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/plugin-api.ts # @hydrooj/framework 重新导出 通过 `hydrooj` 从 `@hydrooj/framework` 重新导出的核心 Web 框架接口。 ## 概述 这些符号是 Hydro 插件中定义路由、处理器和 API 操作的基础构建块。 ```ts import { Apis, APIS, HandlerCommon, httpServer, Mutation, Query, Router, Subscription, WebService, } from 'hydrooj'; ``` ## API 定义辅助工具 | 导出 | 类型 | 说明 | |------|------|------| | `Query` | Function | 定义只读 API 操作;返回 `ApiCall<'Query', Arg, Res>`。 | | `Mutation` | Function | 定义写入 API 操作;返回 `ApiCall<'Mutation', Arg, Res>`。 | | `Subscription` | Function | 定义实时事件流 API 操作;返回带 `emit` 回调的 `ApiCall<'Subscription', Arg, Res>`。 | ## API 注册表 | 导出 | 类型 | 说明 | |------|------|------| | `Apis` | Interface | 描述所有已注册 API 操作按命名空间组织的类型化形状的 TypeScript 接口。 | | `APIS` | Object | 运行时字典,持有所有已注册的 API 调用定义。插件通过 `ApiService.provide()` 将其 API 注册到此对象。 | ## 处理器 | 导出 | 类型 | 说明 | |------|------|------| | `HandlerCommon` | Class | HTTP 和 WebSocket 处理器的基类;提供 `request`、`response`、`args`、`user` 及工具方法如 `checkPerm()`、`checkPriv()`、`url()`、`renderHTML()`。 | ## 路由与服务器 | 导出 | 类型 | 说明 | |------|------|------| | `Router` | Class | 扩展的 Koa Router(`@koa/router`),支持 WebSocket(`ws()` 方法)和可释放的路由注册。 | | `httpServer` | `http.Server` | 底层 Node.js `http.Server` 实例。 | | `WebService` | Class | 核心 Web 服务器服务(继承 cordis `Service`);管理路由/连接注册(`ctx.Route()`、`ctx.Connection()`)、中间件层、处理器混入和模板渲染器。 | --- ## 处理器 ### JudgeHandler > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/handler/judge.ts ```ts import { JudgeHandler, JudgeResultCallbackContext, postJudge } from 'hydrooj' ``` # JudgeHandler 评测系统扩展接口,用于处理评测任务生命周期、结果回调和守护进程通信。 `JudgeHandler` 是整个模块的命名空间重新导出。单独的命名导出(`JudgeResultCallbackContext`、`postJudge`)也可直接使用。 --- ## 类 ### `JudgeResultCallbackContext` 管理单个评测任务结果回调的生命周期。通过内部 promise 链序列化所有 `next`/`end` 操作以确保更新有序。实现了 `then` 方法,因此实例可以直接被 await(在 `end()` 被调用时 resolve)。 **构造函数**: `new JudgeResultCallbackContext(ctx: Context, task: Omit & { type: string })` | 属性 | 类型 | 说明 | |------|------|------| | `ctx` | `Context` | 用于广播事件的 Cordis 上下文 | | `task` | `Omit & { type: string }` | 正在处理的评测任务 | #### 实例方法 | 方法 | 说明 | |------|------| | `next(body: Partial)` | 追加中间评测结果(进度更新)。内部序列化以保证顺序。 | | `end(body?: Partial)` | 结束评测任务。设置 `judgeAt`/`judger`,清除 `progress`,触发 `postJudge`,并 resolve 可等待的 promise。不传 body 则直接 resolve 而不更新。 | | `reset()` | 将记录重置为等待状态并重新入队。当评测守护进程在评测过程中断开连接时使用。 | #### 静态方法 | 方法 | 说明 | |------|------| | `JudgeResultCallbackContext.next(domainId: string, rid: ObjectId, body: Partial)` | 无需实例即可发送中间结果更新。广播 `record/change`。 | | `JudgeResultCallbackContext.end(domainId: string, rid: ObjectId, body: Partial)` | 无需实例即可结束评测任务。设置 `judgeAt`/`judger`,触发 `postJudge`,广播 `record/change`。 | | `JudgeResultCallbackContext.postJudge(rdoc: RecordDoc, context?: JudgeResultCallbackContext)` | 评测后处理:更新题目/比赛状态,递增通过计数,触发 `record/judge` 生命周期钩子。 | ### `JudgeConnectionHandler`(继承 `ConnectionHandler`) 评测守护进程连接的 WebSocket 处理器。管理任务分发、语言配置同步和守护进程生命周期。 > **备注**: 这是 Hydro 核心注册的内部处理器。插件通常不会继承此类。 ### `JudgeFilesDownloadHandler`(继承 `Handler`) 评测文件下载(提交代码和测试数据)的 HTTP 处理器。注册在 `GET/POST /judge/files`,需要 `PRIV_JUDGE`。 ### `JudgeFileUpdateHandler`(继承 `Handler`) 评测文件上传(如 hack 测试数据)的 HTTP 处理器。注册在 `POST /judge/upload`,需要 `PRIV_JUDGE`。 --- ## 函数 ### `processJudgeFileCallback(rid: ObjectId, filename: string, filePath: string): Promise` 验证并上传评测生成的文件作为题目测试数据。在调用 `problem.addTestdata` 之前检查文件数量/大小限制和用户权限。 --- ## 已废弃 > 以下内容已废弃,不应在新代码中使用。 | 导出 | 替代方案 | 说明 | |------|----------|------| | `postJudge(rdoc: RecordDoc)` | `JudgeResultCallbackContext.postJudge(rdoc)` | 作为独立函数的评测后处理 | | `next(payload: any)` | `JudgeResultCallbackContext.next(...)` | 使用 payload 对象的静态式 next 回调 | | `end(payload: any)` | `JudgeResultCallbackContext.end(...)` | 使用 payload 对象的静态式 end 回调 | | `JudgeHandler.apply.next` | — | `apply` 函数上的已废弃别名 | | `JudgeHandler.apply.end` | — | `apply` 函数上的已废弃别名 | --- ## 生命周期 `apply(ctx)` 函数在启动时注册以下内容: - 路由 `judge_files_download` 在 `/judge/files` → `JudgeFilesDownloadHandler`(需要 `PRIV_JUDGE`) - 路由 `judge_files_upload` 在 `/judge/upload` → `JudgeFileUpdateHandler`(需要 `PRIV_JUDGE`) - 连接 `judge_conn` 在 `/judge/conn` → `JudgeConnectionHandler`(需要 `PRIV_JUDGE`) - `record/judge` 事件监听器 —— 处理成功的 hack 提交,自动添加 hack 数据并触发重测 --- ## 备注 - `JudgeResultCallbackContext` 通过内部 promise 链序列化 `next`/`end` 调用 —— 调用方无需处理排序问题。 - 受控重测模式(`meta.rejudge === 'controlled'`)将结果写入 `record.collHistory` 而非实时记录,以便审核后再应用。 - 当评测守护进程断开连接(连接清理)时,所有进行中的任务将通过 `reset()` 重置并重新入队。 --- ## 数据模型 ### BlackListModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/blacklist.ts ```ts import { BlackListModel } from 'hydrooj' ``` # BlackListModel 用于按 ID 封禁用户或实体的黑名单模型,支持可选过期时间。 `BlackListModel` 是纯静态类。所有方法均通过类本身调用(如 `BlackListModel.add(...)`)。 所有方法均使用 `@ArgMethod` 装饰 —— 可通过参数/CLI 模式调用。 --- ## 方法 ### 查找 #### `get(id: string): Promise | null>` 按 ID 查找黑名单条目。未找到时返回 `null`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `id` | `string` | — | 要查找的实体 ID | | **返回值** | `Promise \| null>` | | | ### 创建与变更 #### `add(id: string, expire?: Date | number): Promise>` 将 ID 添加到黑名单,支持可选过期时间。使用 upsert —— 如果条目已存在则更新 `expireAt`。 **过期逻辑:** - `expire === 0` → 从现在起 1000 个月(实际上永久) - `expire` 为 `number` → 从现在起该月数 - `expire` 为 `Date` → 直接使用 - `expire` 省略 → 从现在起 365 天 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `id` | `string` | — | 要封禁的实体 ID | | `expire` | `Date \| number` | `365 天` | 过期时间:Date 直接使用,number 为月数,0 为永久 | | **返回值** | `Promise>` | | 新建或更新后的文档 | ### 删除 #### `del(id: string): Promise` 按 ID 删除黑名单条目。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `id` | `string` | — | 要解封的实体 ID | | **返回值** | `Promise` | | | --- ## 属性 | 属性 | 类型 | 说明 | |------|------|------| | `coll` | `Collection` | MongoDB `blacklist` 集合(模块级 `db.collection` 引用,非类属性) | --- ## 备注 - 集合在 `expireAt` 上使用 MongoDB TTL 索引 —— 过期条目由 MongoDB 自动删除。 - `add` 设置 `expire=0` 时将过期时间设为 1000 个月,并非真正永久。 - TTL 索引在启动时由 `apply()` 创建:`{ expireAt: -1 }`(`expireAfterSeconds: 0`)。 ### BuiltinModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/builtin.ts ```ts import { BuiltinModel } from 'hydrooj' ``` # BuiltinModel 内置常量、权限标志、特权标志、评测状态枚举和 UI 元数据,从 `@hydrooj/common` 重新导出并由 Hydro 扩展。 --- ## 常量 ### PERM —— 域级权限位标志 将权限名映射到 `bigint` 值的对象。在域用户角色上用作位掩码。 | 常量 | 位 | 说明 | |------|-----|------| | `PERM_NONE` | `0n` | 无权限 | | `PERM_VIEW` | `1n << 0` | 查看此域 | | `PERM_EDIT_DOMAIN` | `1n << 1` | 编辑域设置 | | `PERM_MOD_BADGE` | `1n << 2` | 显示 MOD 徽章 | | `PERM_CREATE_PROBLEM` | `1n << 4` | 创建题目 | | `PERM_EDIT_PROBLEM` | `1n << 5` | 编辑任意题目 | | `PERM_EDIT_PROBLEM_SELF` | `1n << 6` | 编辑自己的题目 | | `PERM_VIEW_PROBLEM` | `1n << 7` | 查看题目 | | `PERM_VIEW_PROBLEM_HIDDEN` | `1n << 8` | 查看隐藏题目 | | `PERM_SUBMIT_PROBLEM` | `1n << 9` | 提交题目解答 | | `PERM_READ_PROBLEM_DATA` | `1n << 10` | 读取题目测试数据 | | `PERM_READ_RECORD_CODE` | `1n << 12` | 读取所有记录代码 | | `PERM_REJUDGE_PROBLEM` | `1n << 13` | 重测题目 | | `PERM_REJUDGE` | `1n << 14` | 重测记录 | | `PERM_VIEW_PROBLEM_SOLUTION` | `1n << 15` | 查看题解 | | `PERM_CREATE_PROBLEM_SOLUTION` | `1n << 16` | 创建题解 | | `PERM_VOTE_PROBLEM_SOLUTION` | `1n << 17` | 为题解投票 | | `PERM_EDIT_PROBLEM_SOLUTION` | `1n << 18` | 编辑任意题解 | | `PERM_EDIT_PROBLEM_SOLUTION_SELF` | `1n << 19` | 编辑自己的题解 | | `PERM_DELETE_PROBLEM_SOLUTION` | `1n << 20` | 删除任意题解 | | `PERM_DELETE_PROBLEM_SOLUTION_SELF` | `1n << 21` | 删除自己的题解 | | `PERM_REPLY_PROBLEM_SOLUTION` | `1n << 22` | 回复题解 | | `PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF` | `1n << 24` | 编辑自己的题解回复 | | `PERM_DELETE_PROBLEM_SOLUTION_REPLY` | `1n << 25` | 删除任意题解回复 | | `PERM_DELETE_PROBLEM_SOLUTION_REPLY_SELF` | `1n << 26` | 删除自己的题解回复 | | `PERM_VIEW_DISCUSSION` | `1n << 27` | 查看讨论 | | `PERM_CREATE_DISCUSSION` | `1n << 28` | 创建讨论 | | `PERM_HIGHLIGHT_DISCUSSION` | `1n << 29` | 高亮讨论 | | `PERM_EDIT_DISCUSSION` | `1n << 30` | 编辑任意讨论 | | `PERM_EDIT_DISCUSSION_SELF` | `1n << 31` | 编辑自己的讨论 | | `PERM_DELETE_DISCUSSION` | `1n << 32` | 删除任意讨论 | | `PERM_DELETE_DISCUSSION_SELF` | `1n << 33` | 删除自己的讨论 | | `PERM_REPLY_DISCUSSION` | `1n << 34` | 回复讨论 | | `PERM_EDIT_DISCUSSION_REPLY_SELF` | `1n << 36` | 编辑自己的讨论回复 | | `PERM_DELETE_DISCUSSION_REPLY` | `1n << 38` | 删除任意讨论回复 | | `PERM_DELETE_DISCUSSION_REPLY_SELF` | `1n << 39` | 删除自己的讨论回复 | | `PERM_DELETE_DISCUSSION_REPLY_SELF_DISCUSSION` | `1n << 40` | 删除自己讨论中的回复 | | `PERM_VIEW_CONTEST` | `1n << 41` | 查看比赛 | | `PERM_VIEW_CONTEST_SCOREBOARD` | `1n << 42` | 查看比赛排行榜 | | `PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD` | `1n << 43` | 查看隐藏的比赛排行榜 | | `PERM_CREATE_CONTEST` | `1n << 44` | 创建比赛 | | `PERM_ATTEND_CONTEST` | `1n << 45` | 参加比赛 | | `PERM_VIEW_TRAINING` | `1n << 46` | 查看训练计划 | | `PERM_CREATE_TRAINING` | `1n << 47` | 创建训练计划 | | `PERM_EDIT_TRAINING` | `1n << 48` | 编辑任意训练计划 | | `PERM_EDIT_TRAINING_SELF` | `1n << 49` | 编辑自己的训练计划 | | `PERM_EDIT_CONTEST` | `1n << 50` | 编辑任意比赛 | | `PERM_EDIT_CONTEST_SELF` | `1n << 51` | 编辑自己的比赛 | | `PERM_VIEW_HOMEWORK` | `1n << 52` | 查看作业 | | `PERM_VIEW_HOMEWORK_SCOREBOARD` | `1n << 53` | 查看作业排行榜 | | `PERM_VIEW_HOMEWORK_HIDDEN_SCOREBOARD` | `1n << 54` | 查看隐藏的作业排行榜 | | `PERM_CREATE_HOMEWORK` | `1n << 55` | 创建作业 | | `PERM_ATTEND_HOMEWORK` | `1n << 56` | 认领作业 | | `PERM_EDIT_HOMEWORK` | `1n << 57` | 编辑任意作业 | | `PERM_EDIT_HOMEWORK_SELF` | `1n << 58` | 编辑自己的作业 | | `PERM_VIEW_RANKING` | `1n << 59` | 查看排名 | | `PERM_NEVER` | `1n << 60` | 占位符:永不授予 | | `PERM_PIN_DISCUSSION` | `1n << 61` | 置顶讨论 | | `PERM_ADD_REACTION` | `1n << 62` | 对讨论使用表情回应 | | `PERM_PIN_TRAINING` | `1n << 63` | 置顶训练计划 | | `PERM_LOCK_DISCUSSION` | `1n << 64` | 锁定讨论 | | `PERM_VIEW_PROBLEM_SOLUTION_ACCEPT` | `1n << 65` | 通过后查看题解 | | `PERM_READ_RECORD_CODE_ACCEPT` | `1n << 66` | 通过后查看记录代码 | | `PERM_VIEW_USER_PRIVATE_INFO` | `1n << 67` | 查看域用户私有信息 | | `PERM_VIEW_HIDDEN_CONTEST` | `1n << 68` | 查看所有比赛(含隐藏) | | `PERM_VIEW_HIDDEN_HOMEWORK` | `1n << 69` | 查看所有作业(含隐藏) | | `PERM_VIEW_RECORD` | `1n << 70` | 查看其他用户的记录 | **复合角色**(预计算的组合): | 常量 | 值 | 说明 | |------|-----|------| | `PERM_ALL` | `-1n` | 所有权限(所有位设为 1) | | `PERM_BASIC` | 查看权限的并集 | 访客的基础查看权限 | | `PERM_DEFAULT` | 查看 + 创建/自己编辑/提交/投票/回复/参加的并集 | 注册用户的默认权限 | | `PERM_ADMIN` | `-1n` | `PERM_ALL` 的别名 | ### PRIV —— 系统级特权位标志 将特权名映射到 `number`(位移)值的对象。用于站点范围的用户特权。 | 常量 | 位 | 说明 | |------|-----|------| | `PRIV_NONE` | `0` | 无特权 | | `PRIV_EDIT_SYSTEM` | `1 << 0` | 编辑系统设置(从 `PRIV_SET_PRIV` 重命名) | | `PRIV_SET_PERM` | `1 << 1` | 设置域权限 | | `PRIV_USER_PROFILE` | `1 << 2` | 编辑用户资料 | | `PRIV_REGISTER_USER` | `1 << 3` | 注册新用户 | | `PRIV_READ_PROBLEM_DATA` | `1 << 4` | 读取题目测试数据 | | `PRIV_READ_RECORD_CODE` | `1 << 7` | 读取所有记录代码 | | `PRIV_VIEW_HIDDEN_RECORD` | `1 << 8` | 查看隐藏记录 | | `PRIV_JUDGE` | `1 << 9` | 作为评测节点 | | `PRIV_CREATE_DOMAIN` | `1 << 10` | 创建新域 | | `PRIV_VIEW_ALL_DOMAIN` | `1 << 11` | 查看所有域 | | `PRIV_MANAGE_ALL_DOMAIN` | `1 << 12` | 管理所有域 | | `PRIV_REJUDGE` | `1 << 13` | 站点范围重测记录 | | `PRIV_VIEW_USER_SECRET` | `1 << 14` | 查看用户密钥 | | `PRIV_VIEW_JUDGE_STATISTICS` | `1 << 15` | 查看评测统计 | | `PRIV_CREATE_FILE` | `1 << 16` | 在存储中创建文件 | | `PRIV_UNLIMITED_QUOTA` | `1 << 17` | 绕过存储配额限制 | | `PRIV_DELETE_FILE` | `1 << 18` | 从存储中删除文件 | | `PRIV_NEVER` | `1 << 20` | 占位符:永不授予 | | `PRIV_UNLIMITED_ACCESS` | `1 << 22` | 绕过所有访问检查 | | `PRIV_VIEW_SYSTEM_NOTIFICATION` | `1 << 23` | 查看系统通知 | | `PRIV_SEND_MESSAGE` | `1 << 24` | 发送消息 | | `PRIV_MOD_BADGE` | `1 << 25` | 全局显示 MOD 徽章 | **复合角色**: | 常量 | 值 | 说明 | |------|-----|------| | `PRIV_ALL` | `-1` | 所有特权 | | `PRIV_DEFAULT` | `USER_PROFILE + CREATE_FILE + SEND_MESSAGE` | 注册用户的默认特权 | ### STATUS —— 评测状态枚举 评测结果状态码枚举。 | 值 | 名称 | 说明 | |-----|------|------| | `0` | `STATUS_WAITING` | 等待评测 | | `1` | `STATUS_ACCEPTED` | 答案正确 | | `2` | `STATUS_WRONG_ANSWER` | 答案错误 | | `3` | `STATUS_TIME_LIMIT_EXCEEDED` | 超过时间限制 | | `4` | `STATUS_MEMORY_LIMIT_EXCEEDED` | 超过内存限制 | | `5` | `STATUS_OUTPUT_LIMIT_EXCEEDED` | 超过输出限制 | | `6` | `STATUS_RUNTIME_ERROR` | 运行时错误 | | `7` | `STATUS_COMPILE_ERROR` | 编译错误 | | `8` | `STATUS_SYSTEM_ERROR` | 系统错误 | | `9` | `STATUS_CANCELED` | 提交已取消 | | `10` | `STATUS_ETC` | 未知错误 | | `11` | `STATUS_HACKED` | 解法被 Hack | | `20` | `STATUS_JUDGING` | 正在评测 | | `21` | `STATUS_COMPILING` | 正在编译 | | `22` | `STATUS_FETCHED` | 已被评测机获取 | | `30` | `STATUS_IGNORED` | 提交被忽略 | | `31` | `STATUS_FORMAT_ERROR` | 格式错误 | | `32` | `STATUS_HACK_SUCCESSFUL` | Hack 成功 | | `33` | `STATUS_HACK_UNSUCCESSFUL` | Hack 失败 | ### 状态查找映射 | 导出 | 类型 | 说明 | |------|------|------| | `STATUS_TEXTS` | `Record` | 每个状态码的完整显示名(如 `"Wrong Answer"`) | | `STATUS_SHORT_TEXTS` | `Partial>` | 每个状态码的缩写(如 `"WA"`、`"TLE"`) | | `STATUS_CODES` | `Record` | 语义类别:`"pending"`、`"pass"`、`"fail"`、`"progress"`、`"ignored"` | | `NORMAL_STATUS` | `STATUS[]` | 最终结果状态(AC 到 CE)—— 不包括进行中和特殊状态 | ### 用户性别常量 | 导出 | 类型 | 说明 | |------|------|------| | `USER_GENDER_MALE` | `0` | 男性常量 | | `USER_GENDER_FEMALE` | `1` | 女性常量 | | `USER_GENDER_OTHER` | `2` | 其他性别常量 | | `USER_GENDERS` | `number[]` | 所有性别值的数组 `[0, 1, 2]` | | `USER_GENDER_RANGE` | `Record` | 显示标签:`"Boy ♂"`、`"Girl ♀"`、`"Other"` | | `USER_GENDER_ICONS` | `Record` | 图标符号:`"♂"`、`"♀"`、`"?"` | ### PERMS 所有域级权限描述符的数组,每个包含 `{ family, key, desc }`。按 family 分组: | Family | 权限 | |--------|------| | `perm_general` | `PERM_VIEW`, `PERM_VIEW_USER_PRIVATE_INFO`, `PERM_EDIT_DOMAIN`, `PERM_MOD_BADGE` | | `perm_problem` | `PERM_CREATE_PROBLEM`, `PERM_EDIT_PROBLEM`, `PERM_EDIT_PROBLEM_SELF`, `PERM_VIEW_PROBLEM`, `PERM_VIEW_PROBLEM_HIDDEN`, `PERM_SUBMIT_PROBLEM`, `PERM_READ_PROBLEM_DATA` | | `perm_record` | `PERM_VIEW_RECORD`, `PERM_READ_RECORD_CODE`, `PERM_READ_RECORD_CODE_ACCEPT`, `PERM_REJUDGE_PROBLEM`, `PERM_REJUDGE` | | `perm_problem_solution` | 题解 CRUD、投票和回复的 12 个权限 | | `perm_discussion` | 讨论 CRUD、置顶、高亮、锁定、回应和回复的 15 个权限 | | `perm_contest` | `PERM_VIEW_CONTEST`, `PERM_VIEW_CONTEST_SCOREBOARD`, `PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD`, `PERM_CREATE_CONTEST`, `PERM_ATTEND_CONTEST`, `PERM_EDIT_CONTEST`, `PERM_EDIT_CONTEST_SELF`, `PERM_VIEW_HIDDEN_CONTEST` | | `perm_homework` | `PERM_VIEW_HOMEWORK`, `PERM_VIEW_HOMEWORK_SCOREBOARD`, `PERM_VIEW_HOMEWORK_HIDDEN_SCOREBOARD`, `PERM_CREATE_HOMEWORK`, `PERM_ATTEND_HOMEWORK`, `PERM_EDIT_HOMEWORK`, `PERM_EDIT_HOMEWORK_SELF`, `PERM_VIEW_HIDDEN_HOMEWORK` | | `perm_training` | `PERM_VIEW_TRAINING`, `PERM_CREATE_TRAINING`, `PERM_EDIT_TRAINING`, `PERM_PIN_TRAINING`, `PERM_EDIT_TRAINING_SELF` | | `perm_ranking` | `PERM_VIEW_RANKING` | ### PERMS_BY_FAMILY `Record` —— 按自动生成的 `family` 索引分组的 `PERMS`。用于按类别渲染权限设置 UI。 ### LEVELS `number[]` —— `[100, 90, 70, 55, 40, 30, 20, 10, 5, 2, 1]` —— 11 个用户等级的百分比阈值。排名百分位低于阈值的用户获得该等级。 ### BUILTIN_ROLES 预定义的角色权限集: | 角色 | 值 | 说明 | |------|-----|------| | `guest` | `PERM.PERM_BASIC` | 访客(仅查看)权限 | | `default` | `PERM.PERM_DEFAULT` | 注册用户默认权限 | | `root` | `PERM.PERM_ALL` | 完整管理员权限 | ### DEFAULT_NODES 默认讨论节点分类及其子节点(中文标签)。用于为新域填充初始讨论板结构。 ### CATEGORIES 题目类别分类 —— 一个 `Record`,将顶层算法类别映射到子类别标签。用于题目分类。 --- ## 方法 ### 工具与工厂 #### `getScoreColor(score: number | string): string` 返回数值分数(0–100)对应的十六进制颜色字符串(`#rrggbb`),以 10 分为一档映射红到绿的渐变。非有限值返回 `#000000`。 #### `Permission(family: string, key: bigint, desc: string): PermissionDescriptor` 工厂函数,创建权限描述符对象 `{ family, key, desc }`。内部用于构建 `PERMS` 数组。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `family` | `string` | — | 权限所属分类 | | `key` | `bigint` | — | 权限常量值(如 `PERM.PERM_VIEW`) | | `desc` | `string` | — | 权限描述 | | **返回值** | `PermissionDescriptor` | | `{ family, key, desc }` | --- ## 备注 - `PERM` 标志是 `bigint`(域作用域);`PRIV` 标志是 `number`(系统作用域)。两者都用按位 OR(`|`)组合,按位 AND(`&`)检查。 - `PERM_VIEW_DISPLAYNAME` 已废弃 —— 请使用 `PERM_VIEW_USER_PRIVATE_INFO`(相同位位置 `1n << 67`)。 - `PRIV_EDIT_SYSTEM` 从 `PRIV_SET_PRIV` 重命名而来;`PRIV_JUDGE` 从更早的名称重命名而来。 - 模块在加载时注册自身到 `global.Hydro.model.builtin`。 - 通过 `export * from '@hydrooj/common/permission'` 和 `export * from '@hydrooj/common/status'` 重新导出。 ### ContestModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/contest.ts ```ts import { ContestModel } from 'hydrooj' ``` # ContestModel 比赛模型,用于管理多种评分规则(ACM/ICPC、OI、IOI、IOI Strict、Ledo、Assignment)的比赛、排行榜生成、气球通知、答疑和打印任务。 ContestModel 是导出函数的普通模块(非类)。所有函数直接调用。它将 CRUD 和状态操作委托给共享的 `document` 模块,使用 `TYPE_CONTEST = 30`。 --- ## 类型导出 ### `PrintTaskStatus` 枚举值:`pending`、`printing`、`printed`、`failed`。用于比赛打印任务状态追踪。 --- ## 常量 ### `RULES: ContestRules` 将规则名映射到其规则定义的对象。键:`acm`、`oi`、`homework`、`ioi`、`ledo`、`strictioi`。每个规则定义评分逻辑、排行榜渲染、可见性控制和记录投影行为。 ### `buildContestRule(def): ContestRule` 工厂函数,从部分定义构建新的比赛规则,继承并绑定基础规则中所有未指定的函数。内部用于创建内置规则。 --- ## 方法 ### 状态谓词 根据比赛的 `beginAt`/`endAt` 时间戳评估当前阶段的工具函数。 #### `isNew(tdoc: Tdoc, days?: number): boolean` 比赛在距今 `days` 天之后开始时返回 `true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `tdoc` | `Tdoc` | — | 比赛文档 | | `days` | `number` | `1` | 天数阈值 | | **返回值** | `boolean` | | | #### `isUpcoming(tdoc: Tdoc, days?: number): boolean` 比赛在 `days` 天内开始但尚未开始时返回 `true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `tdoc` | `Tdoc` | — | 比赛文档 | | `days` | `number` | `7` | 天数阈值 | | **返回值** | `boolean` | | | #### `isNotStarted(tdoc: Tdoc): boolean` 当前时间早于 `tdoc.beginAt` 时返回 `true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `tdoc` | `Tdoc` | — | 比赛文档 | | **返回值** | `boolean` | | | #### `isOngoing(tdoc: Tdoc, tsdoc?: any): boolean` 当前时间在 `beginAt` 和 `endAt` 之间时返回 `true`。对于限时比赛,还会检查用户的 `startAt` 未超过允许的时长。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `tdoc` | `Tdoc` | — | 比赛文档 | | `tsdoc` | `any` | — | 用户比赛状态文档 | | **返回值** | `boolean` | | | #### `isDone(tdoc: Tdoc, tsdoc?: any): boolean` 比赛已结束时返回 `true`。对于限时比赛,还会考虑用户的 `startAt` 加上时长。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `tdoc` | `Tdoc` | — | 比赛文档 | | `tsdoc` | `any` | — | 用户比赛状态文档 | | **返回值** | `boolean` | | | #### `isLocked(tdoc: Tdoc, time?: Date): boolean` 排行榜已锁定(`lockAt` 已设置且已过)且尚未解锁时返回 `true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `tdoc` | `Tdoc` | — | 比赛文档 | | `time` | `Date` | `new Date()` | 用于比较的时间点 | | **返回值** | `boolean` | | | #### `isExtended(tdoc: Tdoc): boolean` 当前时间在罚时/延期时段(在 `penaltySince` 和 `endAt` 之间)时返回 `true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `tdoc` | `Tdoc` | — | 比赛文档 | | **返回值** | `boolean` | | | #### `statusText(tdoc: Tdoc, tsdoc?: any): string` 返回可读的状态字符串:`'New'`、`'Ready (☆▽☆)'`、`'Live...'` 或 `'Done'`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `tdoc` | `Tdoc` | — | 比赛文档 | | `tsdoc` | `any` | — | 用户比赛状态文档 | | **返回值** | `string` | | | --- ### CRUD #### `add(domainId: string, title: string, content: string, owner: number, rule: string, beginAt?: Date, endAt?: Date, pids?: number[], rated?: boolean, data?: any): Promise` 创建新比赛。验证规则存在且 `beginAt < endAt`。触发 `contest/before-add` 和 `contest/add` 总线事件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `title` | `string` | — | 比赛标题 | | `content` | `string` | — | 比赛描述/正文 | | `owner` | `number` | — | 创建者 UID | | `rule` | `string` | — | 比赛规则名(`acm`、`oi`、`ioi` 等) | | `beginAt` | `Date` | `new Date()` | 开始时间 | | `endAt` | `Date` | `new Date()` | 结束时间 | | `pids` | `number[]` | `[]` | 题目 ID 列表 | | `rated` | `boolean` | `false` | 是否为 rated 比赛 | | `data` | `Partial` | `{}` | 附加数据(如比赛特定配置) | | **返回值** | `Promise` | | 新比赛 ID | ```typescript // 创建 ACM 规则的比赛 const tid = await contest.add( 'system', '2024 校内选拔赛', '## 比赛说明\n...', session.uid, 'acm', new Date('2024-06-01T09:00:00'), new Date('2024-06-01T14:00:00'), [1001, 1002, 1003, 1004, 1005], true, ); ``` #### `edit(domainId: string, tid: ObjectId, $set: Partial): Promise` 更新比赛字段。如果规则有变更则验证。触发 `contest/before-edit` 和 `contest/edit` 总线事件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `$set` | `Partial` | — | 要更新的字段 | | **返回值** | `Promise` | | 更新后的比赛文档 | ```typescript // 延长比赛结束时间 const updated = await contest.edit( 'system', tid, { endAt: new Date('2024-06-01T15:00:00') }, ); // 修改比赛规则并更新题目列表 await contest.edit('system', tid, { rule: 'oi', pids: [1001, 1002, 1003], }); ``` #### `del(domainId: string, tid: ObjectId): Promise` 删除比赛及所有关联的用户状态。触发 `contest/del` 总线事件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | **返回值** | `Promise` | | | #### `get(domainId: string, tid: ObjectId): Promise` 按 ID 获取单个比赛。未找到时抛出 `ContestNotFoundError`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | **返回值** | `Promise` | | 比赛文档 | #### `getMulti(domainId: string, query?: any): FindCursor` 返回匹配查询的比赛游标,按 `beginAt` 降序排列。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `any` | — | MongoDB 查询过滤器 | | **返回值** | `FindCursor` | | 比赛游标 | #### `getRelated(domainId: string, pid: number, rule?: string): Promise` 查找包含指定题目(`pids` 中包含 `pid`)的比赛。除非指定了 `rule`,否则过滤隐藏规则。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 ID | | `rule` | `string` | — | 按规则过滤(如 `'acm'`) | | **返回值** | `Promise` | | 关联的比赛列表 | #### `count(domainId: string, query: any): Promise` 返回匹配查询的比赛数量。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `any` | — | MongoDB 查询过滤器 | | **返回值** | `Promise` | | 匹配数量 | --- ### 状态管理 #### `getStatus(domainId: string, tid: ObjectId, uid: number): Promise` 获取单个用户的比赛状态。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `uid` | `number` | — | 用户 ID | | **返回值** | `Promise` | | 用户状态或 `null` | #### `getMultiStatus(domainId: string, query: any): FindCursor` 返回匹配查询的比赛状态游标。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `any` | — | MongoDB 查询过滤器 | | **返回值** | `FindCursor` | | 状态游标 | #### `getListStatus(domainId: string, uid: number, tids: ObjectId[]): Promise>` 批量获取指定用户多场比赛的状态,以 `tid.toHexString()` 为键的映射返回。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `uid` | `number` | — | 用户 ID | | `tids` | `ObjectId[]` | — | 比赛 ID 数组 | | **返回值** | `Promise>` | | 以十六进制 ID 为键的状态映射 | #### `setStatus(domainId: string, tid: ObjectId, uid: number, $set: any): Promise` 覆盖用户在指定比赛上的状态字段。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `uid` | `number` | — | 用户 ID | | `$set` | `any` | — | 要设置的状态字段 | | **返回值** | `Promise` | | | #### `updateStatus(domainId: string, tid: ObjectId, uid: number, rid: ObjectId, pid: number, { status?, score?, subtasks?, lang? }?: { status?: STATUS, score?: number, subtasks?: Record, lang?: string }): Promise` 推入新的日志条目(提交结果),并使用比赛规则的 `stat` 函数重新计算用户统计。使用基于修订号的状态更新以确保并发安全。同时对通过的提交触发气球创建。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `uid` | `number` | — | 用户 ID | | `rid` | `ObjectId` | — | 评测记录 ID | | `pid` | `number` | — | 题目 ID | | `opts` | `{ status?: STATUS, score?: number, subtasks?: Record, lang?: string }` | `{}` | 额外选项(评测状态、分数、子任务结果、语言) | | **返回值** | `Promise` | | 更新后的用户状态 | ```typescript // 提交通过后更新比赛状态 const tsdoc = await contest.updateStatus( 'system', tid, session.uid, rid, 1001, { status: STATUS.STATUS_ACCEPTED, score: 100 }, ); ``` #### `countStatus(domainId: string, query: any): Promise` 返回匹配查询的比赛状态数量。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `any` | — | MongoDB 查询过滤器 | | **返回值** | `Promise` | | 匹配数量 | #### `attend(domainId: string, tid: ObjectId, uid: number, payload?: any): Promise<{}>` 为用户报名比赛。已报名时抛出 `ContestAlreadyAttendedError`。使用 `cappedIncStatus` 原子性防止重复报名。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `uid` | `number` | — | 用户 ID | | `payload` | `any` | — | 附加报名信息 | | **返回值** | `Promise<{}>` | | | ```typescript // 用户报名比赛 await contest.attend('system', tid, session.uid); // 带附加信息报名(如队伍名) await contest.attend('system', tid, session.uid, { teamName: '测试小队', }); ``` #### `getAndListStatus(domainId: string, tid: ObjectId): Promise<[Tdoc, Tsdoc[]]>` 获取比赛文档及按规则的 `statusSort` 排序的所有用户状态。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | **返回值** | `Promise<[Tdoc, Tsdoc[]]>` | | 比赛文档和排序后的状态列表 | #### `recalcStatus(domainId: string, tid: ObjectId): Promise` 使用比赛规则的 `stat` 函数从日志重新计算所有用户状态。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | **返回值** | `Promise` | | 重新计算后的状态列表 | #### `unlockScoreboard(domainId: string, tid: ObjectId): Promise` 解锁已锁定的排行榜,设置 `unlocked: true` 并重新计算所有状态。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | **返回值** | `Promise` | | | --- ### 排行榜 检查用户是否可以查看某些比赛信息的函数。均使用 `this` 上下文,包含 `{ user: User }`。 #### `canViewHiddenScoreboard(this: { user }, tdoc: Tdoc): boolean` 用户拥有比赛或具有 `PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD`(作业为 `PERM_VIEW_HOMEWORK_HIDDEN_SCOREBOARD`)时返回 `true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `this` | `{ user: User }` | — | 上下文,包含当前用户 | | `tdoc` | `Tdoc` | — | 比赛文档 | | **返回值** | `boolean` | | | #### `canShowRecord(this: { user }, tdoc: Tdoc, allowPermOverride?: boolean): boolean` 比赛规则允许在当前时间显示所有记录,或用户具有排行榜覆盖权限时返回 `true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `this` | `{ user: User }` | — | 上下文,包含当前用户 | | `tdoc` | `Tdoc` | — | 比赛文档 | | `allowPermOverride` | `boolean` | `true` | 是否允许权限覆盖 | | **返回值** | `boolean` | | | #### `canShowSelfRecord(this: { user }, tdoc: Tdoc, allowPermOverride?: boolean): boolean` 比赛规则允许显示用户自己的记录,或用户具有排行榜覆盖权限时返回 `true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `this` | `{ user: User }` | — | 上下文,包含当前用户 | | `tdoc` | `Tdoc` | — | 比赛文档 | | `allowPermOverride` | `boolean` | `true` | 是否允许权限覆盖 | | **返回值** | `boolean` | | | #### `canShowScoreboard(this: { user }, tdoc: Tdoc, allowPermOverride?: boolean): boolean` 比赛规则允许显示排行榜,或用户具有排行榜覆盖权限时返回 `true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `this` | `{ user: User }` | — | 上下文,包含当前用户 | | `tdoc` | `Tdoc` | — | 比赛文档 | | `allowPermOverride` | `boolean` | `true` | 是否允许权限覆盖 | | **返回值** | `boolean` | | | #### `getScoreboard(this: Handler, domainId: string, tid: ObjectId, config: any): Promise<[Tdoc, ScoreboardRow[], BaseUserDict, ProblemDict]>` 使用规则的 `scoreboard` 函数构建完整排行榜。排行榜不可见时抛出 `ContestScoreboardHiddenError`。触发 `contest/scoreboard` 总线事件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `this` | `Handler` | — | 请求处理上下文 | | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `config` | `any` | — | 排行榜配置选项 | | **返回值** | `Promise<[Tdoc, ScoreboardRow[], BaseUserDict, ProblemDict]>` | | 比赛文档、排行榜行、用户字典、题目字典 | ```typescript // 获取完整排行榜 const [tdoc, rows, udict, pdict] = await contest.getScoreboard.call( handler, 'system', tid, { showDisplayName: true }, ); // rows 为排行数据,udict 为用户信息字典,pdict 为题目信息字典 for (const row of rows) { console.log(row.rank, udict[row.uid]?.uname, row.score); } ``` --- ### 气球 ACM 风格首 A 通知的气球管理。 #### `addBalloon(domainId: string, tid: ObjectId, uid: number, rid: ObjectId, pid: number): Promise` 为通过的提交添加气球。判断是否为该题目的首次通过。触发 `contest/balloon` 事件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `uid` | `number` | — | 用户 ID | | `rid` | `ObjectId` | — | 评测记录 ID | | `pid` | `number` | — | 题目 ID | | **返回值** | `Promise` | | 气球 ID,非首次通过返回 `null` | #### `getBalloon(domainId: string, tid: ObjectId, _id: ObjectId): Promise` 按 ID 获取单个气球。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `_id` | `ObjectId` | — | 气球 ID | | **返回值** | `Promise` | | 气球文档 | #### `getMultiBalloon(domainId: string, tid: ObjectId, query?: any): FindCursor` 返回比赛的气球游标。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `query` | `any` | — | MongoDB 查询过滤器 | | **返回值** | `FindCursor` | | 气球游标 | #### `updateBalloon(domainId: string, tid: ObjectId, _id: ObjectId, $set: any): Promise` 更新气球字段。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `_id` | `ObjectId` | — | 气球 ID | | `$set` | `any` | — | 要更新的字段 | | **返回值** | `Promise` | | 更新后的气球文档 | --- ### 答疑 比赛答疑(提问/回答)管理,以 `TYPE_CONTEST_CLARIFICATION` 类型的子文档存储。 #### `addClarification(domainId: string, tid: ObjectId, owner: number, content: string, ip: string, subject?: number): Promise` 在比赛上创建新的答疑问题。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `owner` | `number` | — | 提问者 UID | | `content` | `string` | — | 问题内容 | | `ip` | `string` | — | 提问者 IP 地址 | | `subject` | `number` | `0` | 答疑主题 | | **返回值** | `Promise` | | 答疑 ID | #### `addClarificationReply(domainId: string, did: ObjectId, owner: number, content: string, ip: string): Promise<[any, ObjectId]>` 为已有答疑追加回复。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 答疑 ID | | `owner` | `number` | — | 回复者 UID | | `content` | `string` | — | 回复内容 | | `ip` | `string` | — | 回复者 IP 地址 | | **返回值** | `Promise<[any, ObjectId]>` | | 更新后的文档与回复 ID | #### `getClarification(domainId: string, did: ObjectId): Promise` 按 ID 获取单个答疑。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 答疑 ID | | **返回值** | `Promise` | | 答疑文档 | #### `getMultiClarification(domainId: string, tid: ObjectId, owner?: number): Promise` 列出比赛的答疑。如果指定 `owner`,则仅包含该用户可见的答疑(owner `$in: [owner, 0]`)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `owner` | `number` | — | 过滤可见答疑的用户 ID | | **返回值** | `Promise` | | 答疑列表 | --- ### 打印任务 现场比赛的打印任务管理。使用 `TYPE_CONTEST_PRINT`。 #### `addPrintTask(domainId: string, tid: ObjectId, uid: number, name: string, content: string): Promise` 创建 `pending` 状态的新打印任务。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `uid` | `number` | — | 提交者 UID | | `name` | `string` | — | 打印任务名称 | | `content` | `string` | — | 打印内容 | | **返回值** | `Promise` | | 打印任务 ID | #### `updatePrintTask(domainId: string, tid: ObjectId, taskId: ObjectId, $set: any): Promise` 更新打印任务字段。修改成功返回 `true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `taskId` | `ObjectId` | — | 打印任务 ID | | `$set` | `any` | — | 要更新的字段 | | **返回值** | `Promise` | | 是否修改成功 | #### `allocatePrintTask(domainId: string, tid: ObjectId): Promise` 原子性地领取下一个待处理打印任务,将其状态设为 `printing`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | **返回值** | `Promise` | | 打印任务文档或 `null`(无待处理任务) | #### `getMultiPrintTask(domainId: string, tid: ObjectId, query?: any): FindCursor` 返回比赛的打印任务游标,按 `_id` 升序排列。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `tid` | `ObjectId` | — | 比赛 ID | | `query` | `any` | — | MongoDB 查询过滤器 | | **返回值** | `FindCursor` | | 打印任务游标 | --- ### 其他 #### `applyProjection(tdoc: Tdoc, rdoc: RecordDoc, udoc: User): RecordDoc` 应用比赛规则的 `applyProjection`,在比赛进行期间脱敏记录中的敏感字段(分数、时间、内存、测试用例等)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `tdoc` | `Tdoc` | — | 比赛文档 | | `rdoc` | `RecordDoc` | — | 评测记录文档 | | `udoc` | `User` | — | 用户文档 | | **返回值** | `RecordDoc` | | 脱敏后的记录文档 | #### `apply(ctx: Context): Promise` 生命周期钩子。注册 `contest/balloon` 事件监听器(发送首 A 消息)并确保气球集合上的数据库索引。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `ctx` | `Context` | — | 插件上下文 | | **返回值** | `Promise` | | | --- ## 备注 - 比赛是文档类型模型(`TYPE_CONTEST = 30`)。CRUD 和状态操作委托给共享的 `document` 模块。 - 六种内置规则:`acm`(XCPC)、`oi`、`ioi`、`strictioi`、`ledo`、`homework`(隐藏)。每个规则定义 `stat`、`scoreboard`、`scoreboardRow`、`scoreboardHeader`、`showScoreboard`、`showRecord`、`showSelfRecord`、`applyProjection` 和 `check`。 - `updateStatus` 使用基于修订号的状态(`revPushStatus` + `revSetStatus`)实现日志更新的乐观并发控制。 - `attend` 使用 `cappedIncStatus`(上限为 1)原子性防止重复报名;重新抛出为 `ContestAlreadyAttendedError`。 - `add` 和 `edit` 触发前/后总线事件(`contest/before-add`、`contest/add`、`contest/before-edit`、`contest/edit`)。 - 答疑使用 `TYPE_CONTEST_CLARIFICATION` 作为独立 docType,通过父引用关联比赛。 - 打印任务使用 `TYPE_CONTEST_PRINT` 作为独立 docType,通过父引用关联比赛。 ### DiscussionModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/discussion.ts ```ts import { DiscussionModel } from 'hydrooj' ``` # DiscussionModel 讨论(论坛)模型,用于管理线程化讨论、回复、嵌套尾隔回复、表情回应、讨论节点(分类)和父实体解析。 DiscussionModel 是导出函数的普通模块(非类)。所有函数直接调用(如 `DiscussionModel.add(...)`)。 --- ## 类型导出 ### `DiscussionDoc` 继承 `Document` —— 讨论文档的形状。 ### `Field` ```typescript type Field = keyof DiscussionDoc ``` 所有讨论文档字段名的联合类型。 --- ## 常量 ### `typeDisplay: Record` 将文档类型常量映射到可读名称:`{ 10: 'problem', 20: 'node', 30: 'contest', 40: 'training' }`。 ### `PROJECTION_LIST: Field[]` 列表视图中返回的字段:`_id`、`domainId`、`docType`、`docId`、`highlight`、`nReply`、`views`、`pin`、`updateAt`、`owner`、`parentId`、`parentType`、`title`、`hidden`。 ### `PROJECTION_PUBLIC: Field[]` 详情视图中返回的字段 —— 在 `PROJECTION_LIST` 基础上增加 `content`、`edited`、`react`、`maintainer`、`lock`。 ### `HISTORY_PROJECTION_PUBLIC: (keyof DiscussionHistoryDoc)[]` 历史视图中返回的字段:`title`、`content`、`docId`、`uid`、`time`。 --- ## 方法 ### 讨论 CRUD #### `add(domainId: string, parentType: number, parentId: ObjectId | number | string, owner: number, title: string, content: string, ip: string | null = null, highlight: boolean, pin: boolean, hidden?: boolean): Promise` 在父实体(题目、比赛、训练计划或节点)下创建新讨论。触发 `discussion/before-add` 和 `discussion/add` 总线事件。返回新讨论 ID。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `parentType` | `number` | — | 父实体类型常量 | | `parentId` | `ObjectId \| number \| string` | — | 父实体 ID | | `owner` | `number` | — | 创建者 UID | | `title` | `string` | — | 讨论标题 | | `content` | `string` | — | 讨论内容 | | `ip` | `string \| null` | `null` | 创建者 IP 地址 | | `highlight` | `boolean` | — | 是否高亮 | | `pin` | `boolean` | — | 是否置顶 | | `hidden` | `boolean` | `false` | 是否隐藏 | | **返回值** | `Promise` | | 新讨论 ID | ```typescript // 在题目下创建讨论 const did = await DiscussionModel.add( 'system', // domainId 10, // parentType (TYPE_PROBLEM) problemId, // parentId 12345, // owner '关于时间限制的疑问', // title '这道题的 1s 时限是否合理?', // content '127.0.0.1', // ip false, // highlight false, // pin ); // 在讨论节点下创建置顶讨论 const pinnedDid = await DiscussionModel.add( 'system', 30, // parentType (TYPE_DISCUSSION_NODE) 'general', // parentId (节点字符串 ID) 12345, '版规公告', '请遵守社区规范...', undefined, // ip true, // highlight true, // pin false, // hidden ); ``` #### `get(domainId: string, did: ObjectId, projection?: T[]): Promise>` 按 ID 获取单个讨论,支持指定字段投影。默认为 `PROJECTION_PUBLIC`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 讨论 ID | | `projection` | `T[]` | `PROJECTION_PUBLIC` | 返回字段列表 | | **返回值** | `Promise>` | | | #### `edit(domainId: string, did: ObjectId, $set: Partial): Promise` 更新讨论字段。如果 `content` 有变更,自动在 `coll` 集合中插入历史记录。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 讨论 ID | | `$set` | `Partial` | — | 要更新的字段 | | **返回值** | `Promise` | | | #### `del(domainId: string, did: ObjectId): Promise` 删除讨论及所有关联的回复、状态和历史记录。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 讨论 ID | | **返回值** | `Promise` | | | #### `inc(domainId: string, did: ObjectId, key: NumberKeys, value: number): Promise` 原子性递增讨论上的数字字段(如 `views`)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 讨论 ID | | `key` | `NumberKeys` | — | 要递增的字段名 | | `value` | `number` | — | 递增量 | | **返回值** | `Promise` | | | #### `getMulti(domainId: string, query?: Filter, projection?: Field[]): FindCursor` 返回匹配查询的讨论游标,按 `pin` 降序然后 `docId` 降序排列。默认为 `PROJECTION_LIST`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `Filter` | — | 过滤条件 | | `projection` | `Field[]` | `PROJECTION_LIST` | 返回字段列表 | | **返回值** | `FindCursor` | | | #### `count(domainId: string, query: Filter): Promise` 返回匹配给定查询的讨论数量。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `Filter` | — | 过滤条件 | | **返回值** | `Promise` | | | ### 回复 CRUD #### `addReply(domainId: string, did: ObjectId, owner: number, content: string, ip: string): Promise` 为讨论添加回复。原子性递增 `nReply` 并更新父讨论的 `updateAt`。返回新回复 ID。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 讨论 ID | | `owner` | `number` | — | 回复者 UID | | `content` | `string` | — | 回复内容 | | `ip` | `string` | — | 回复者 IP 地址 | | **返回值** | `Promise` | | 新回复 ID | #### `getReply(domainId: string, drid: ObjectId): Promise` 按 ID 获取单个回复。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `drid` | `ObjectId` | — | 回复 ID | | **返回值** | `Promise` | | | #### `editReply(domainId: string, drid: ObjectId, content: string, uid: number, ip: string): Promise` 更新回复内容。自动插入历史记录并设置 `edited: true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `drid` | `ObjectId` | — | 回复 ID | | `content` | `string` | — | 新内容 | | `uid` | `number` | — | 编辑者 UID | | `ip` | `string` | — | 编辑者 IP 地址 | | **返回值** | `Promise` | | | #### `delReply(domainId: string, drid: ObjectId): Promise` 删除回复及所有尾隔回复和历史记录。原子性递减父讨论的 `nReply`。回复不存在时抛出 `DocumentNotFoundError`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `drid` | `ObjectId` | — | 回复 ID | | **返回值** | `Promise` | | | #### `getMultiReply(domainId: string, did: ObjectId): FindCursor` 返回讨论的回复游标,按 `_id` 降序排列。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 讨论 ID | | **返回值** | `FindCursor` | | | #### `getListReply(domainId: string, did: ObjectId): Promise` 以数组形式返回讨论的所有回复(`getMultiReply` 的便捷封装)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 讨论 ID | | **返回值** | `Promise` | | | ### 尾隔回复 尾隔回复是嵌套在顶级回复中的二级回复,存储在 `DiscussionReplyDoc` 的 `reply` 数组字段中。 #### `addTailReply(domainId: string, drid: ObjectId, owner: number, content: string, ip: string): Promise<[DiscussionReplyDoc, ObjectId]>` 为顶级回复添加嵌套回复。同时更新父讨论的 `updateAt`。返回更新后的父回复文档和新尾隔回复 ID。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `drid` | `ObjectId` | — | 父回复 ID | | `owner` | `number` | — | 回复者 UID | | `content` | `string` | — | 回复内容 | | `ip` | `string` | — | 回复者 IP 地址 | | **返回值** | `Promise<[DiscussionReplyDoc, ObjectId]>` | | 父回复文档和新尾隔回复 ID | ```typescript // 向回复添加尾隔回复(二级嵌套) const [updatedReply, tailReplyId] = await DiscussionModel.addTailReply( 'system', // domainId replyId, // drid (父回复 ID) 12345, // owner '感谢解答,非常清楚!', // content '127.0.0.1', // ip ); console.log('新尾隔回复 ID:', tailReplyId); ``` #### `getTailReply(domainId: string, drid: ObjectId, drrid: ObjectId): Promise<[DiscussionReplyDoc, DiscussionTailReplyDoc] | [null, null]>` 获取父回复中的指定尾隔回复。未找到时返回 `[null, null]`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `drid` | `ObjectId` | — | 父回复 ID | | `drrid` | `ObjectId` | — | 尾隔回复 ID | | **返回值** | `Promise<[DiscussionReplyDoc, DiscussionTailReplyDoc] \| [null, null]>` | | | #### `editTailReply(domainId: string, drid: ObjectId, drrid: ObjectId, content: string, uid: number, ip: string): Promise` 更新尾隔回复内容。自动插入历史记录并设置 `edited: true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `drid` | `ObjectId` | — | 父回复 ID | | `drrid` | `ObjectId` | — | 尾隔回复 ID | | `content` | `string` | — | 新内容 | | `uid` | `number` | — | 编辑者 UID | | `ip` | `string` | — | 编辑者 IP 地址 | | **返回值** | `Promise` | | | #### `delTailReply(domainId: string, drid: ObjectId, drrid: ObjectId): Promise<[void, void]>` 删除尾隔回复及其关联的历史记录。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `drid` | `ObjectId` | — | 父回复 ID | | `drrid` | `ObjectId` | — | 尾隔回复 ID | | **返回值** | `Promise<[void, void]>` | | | ### 表情回应 #### `react(domainId: string, docType: keyof DocType, did: ObjectId, id: string, uid: number, reverse?: boolean): Promise<[any, any]>` 切换用户对讨论或回复的表情回应(`id`)。如果 `reverse` 为 true,则移除回应。返回 `[updatedDoc, statusDoc]`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `keyof DocType` | — | 文档类型 | | `did` | `ObjectId` | — | 讨论/回复 ID | | `id` | `string` | — | emoji ID | | `uid` | `number` | — | 用户 ID | | `reverse` | `boolean` | `false` | 为 `true` 时移除回应 | | **返回值** | `Promise<[any, any]>` | | `[updatedDoc, statusDoc]` | ```typescript // 对讨论点赞(emoji ID "like") const [doc, status] = await DiscussionModel.react( 'system', // domainId 21, // docType (TYPE_DISCUSSION) discussionId, // did 'like', // id (emoji ID) 12345, // uid ); // 取消点赞 await DiscussionModel.react( 'system', 21, discussionId, 'like', 12345, true, // reverse = true → 移除 ); ``` #### `getReaction(domainId: string, docType: keyof DocType, did: ObjectId, uid: number): Promise>` 返回用户对文档的回应状态,以 emoji ID 到值的映射返回。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `keyof DocType` | — | 文档类型 | | `did` | `ObjectId` | — | 文档 ID | | `uid` | `number` | — | 用户 ID | | **返回值** | `Promise>` | | emoji ID → 值的映射 | ### 历史 #### `getHistory(domainId: string, docId: ObjectId, query?: Filter, projection?: (keyof DiscussionHistoryDoc)[]): Promise` 返回讨论或回复的编辑历史记录,按 `time` 降序排列。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docId` | `ObjectId` | — | 讨论/回复 ID | | `query` | `Filter` | — | 过滤条件 | | `projection` | `(keyof DiscussionHistoryDoc)[]` | — | 返回字段列表 | | **返回值** | `Promise` | | | ### 用户状态 #### `setStar(domainId: string, did: ObjectId, uid: number, star: boolean): Promise` 设置或清除用户对讨论的星标。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 讨论 ID | | `uid` | `number` | — | 用户 ID | | `star` | `boolean` | — | `true` 星标,`false` 取消 | | **返回值** | `Promise` | | | #### `getStatus(domainId: string, did: ObjectId, uid: number): Promise` 获取用户对讨论的状态记录(包括星标、回应等)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 讨论 ID | | `uid` | `number` | — | 用户 ID | | **返回值** | `Promise` | | | #### `setStatus(domainId: string, did: ObjectId, uid: number, $set: any): Promise` 覆盖用户对讨论的状态字段。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `did` | `ObjectId` | — | 讨论 ID | | `uid` | `number` | — | 用户 ID | | `$set` | `any` | — | 要设置的状态字段 | | **返回值** | `Promise` | | | ### 节点(分类) 讨论节点作为顶层分类,用于组织讨论。 #### `addNode(domainId: string, _id: string, category: string, args?: any): Promise` 创建具有指定 ID 和分类名称的讨论节点。可选 `args` 用于附加字段。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `_id` | `string` | — | 节点 ID | | `category` | `string` | — | 分类名称 | | `args` | `any` | — | 附加字段 | | **返回值** | `Promise` | | | #### `getNode(domainId: string, _id: string): Promise` 按字符串 ID 获取单个讨论节点。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `_id` | `string` | — | 节点 ID | | **返回值** | `Promise` | | | #### `getNodes(domainId: string): Promise` 以数组形式返回域的所有讨论节点。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | **返回值** | `Promise` | | | #### `flushNodes(domainId: string): Promise` 删除域的所有讨论节点。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | **返回值** | `Promise` | | | ### 虚拟节点(父实体) 虚拟节点解析讨论所附属的父实体(题目、比赛、训练计划或讨论节点)。 #### `getVnode(domainId: string, type: number, id: string, uid?: number): Promise` 解析讨论的父实体。处理题目(按数字 ID)、比赛/训练计划(按 ObjectId)和讨论节点(按字符串 ID)。可选地为指定用户填充 `attend` 状态。未找到时抛出 `DiscussionNodeNotFoundError`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `type` | `number` | — | 父实体类型 | | `id` | `string` | — | 父实体 ID | | `uid` | `number` | — | 用于填充 `attend` 状态的用户 ID | | **返回值** | `Promise` | | | #### `getListVnodes(domainId: string, ddocs: any, getHidden?: boolean, assign?: string[]): Promise>>` 批量解析多个讨论的父实体。返回嵌套映射 `{ [parentType]: { [parentId]: vnode } }`。默认过滤隐藏节点和受作业组限制的项目。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `ddocs` | `any` | — | 讨论文档数组 | | `getHidden` | `boolean` | `false` | 是否包含隐藏节点 | | `assign` | `string[]` | — | 作业组限制过滤 | | **返回值** | `Promise>>` | | | #### `checkVNodeVisibility(type: number, vnode: any, user: User): boolean` 用户被允许查看父实体时返回 `true`。检查隐藏题目可见性和比赛/训练计划的作业组限制。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `type` | `number` | — | 父实体类型 | | `vnode` | `any` | — | 虚拟节点对象 | | `user` | `User` | — | 当前用户 | | **返回值** | `boolean` | | 是否可见 | --- ## 属性 | 属性 | 类型 | 说明 | |------|------|------| | `coll` | `Collection` | MongoDB `discussion.history` 集合 —— 存储编辑历史记录 | --- ## 备注 - 讨论是文档类型模型(`TYPE_DISCUSSION = 21`)。CRUD 和状态操作委托给共享的 `document` 模块。 - 模型支持三级嵌套:**讨论** → **回复** → **尾隔回复**。 - 所有内容变更(讨论编辑、回复编辑、尾隔回复编辑)会自动在 `discussion.history` 集合中插入历史记录。 - `add()` 触发总线事件(`discussion/before-add`、`discussion/add`),使插件可以拦截或响应讨论创建。 - `apply(ctx)` 注册生命周期钩子:题目删除时级联删除关联讨论,题目编辑时同步 `hidden` 状态到关联讨论。 ### DocumentModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/document.ts ```ts import { DocumentModel } from 'hydrooj' ``` # DocumentModel 通用文档存储模型,提供跨所有文档类型(题目、比赛、训练计划、讨论等)的类型化 CRUD、子文档操作和每用户状态追踪。 DocumentModel 是导出函数的普通模块(非类)。所有函数直接调用。它是高级模型(ProblemModel、ContestModel、TrainingModel、DiscussionModel)委托的基础数据访问层。 --- ## 类型导出 ### DocType 将类型常量映射到对应的文档接口: ```typescript interface DocType { [TYPE_PROBLEM]: ProblemDoc; [TYPE_PROBLEM_SOLUTION]: any; [TYPE_PROBLEM_LIST]: any; [TYPE_DISCUSSION_NODE]: any; [TYPE_DISCUSSION]: DiscussionDoc; [TYPE_DISCUSSION_REPLY]: DiscussionReplyDoc; [TYPE_CONTEST]: Tdoc; [TYPE_CONTEST_PRINT]: ContestPrintDoc; [TYPE_CONTEST_CLARIFICATION]: ContestClarificationDoc; [TYPE_TRAINING]: TrainingDoc; } ``` ### DocStatusType 将类型常量映射到状态文档接口(如 `TYPE_PROBLEM` 对应 `ProblemStatusDoc`)。 --- ## 常量 ### DocType 常量 | 常量 | 值 | 说明 | |------|-----|------| | `TYPE_PROBLEM` | `10` | 题目文档 | | `TYPE_PROBLEM_SOLUTION` | `11` | 题解题 | | `TYPE_PROBLEM_LIST` | `12` | 题单 / 作业 | | `TYPE_DISCUSSION_NODE` | `20` | 讨论分类节点 | | `TYPE_DISCUSSION` | `21` | 讨论帖 | | `TYPE_DISCUSSION_REPLY` | `22` | 讨论回复 | | `TYPE_CONTEST` | `30` | 比赛文档 | | `TYPE_CONTEST_CLARIFICATION` | `31` | 比赛答疑请求 | | `TYPE_CONTEST_PRINT` | `32` | 比赛打印任务 | | `TYPE_TRAINING` | `40` | 训练计划 | --- ## 方法 ### 文档 CRUD #### `add(domainId: string, content: string, owner: number, docType: number, docId?: ObjectId, parentType?: number, parentId?: ObjectId | number | string, args?: any): Promise` 插入新文档。如果提供了 `docId` 则返回 `docId`,否则返回生成的 `ObjectId`。插入前触发 `document/add` 总线事件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `content` | `string` | — | 文档内容 | | `owner` | `number` | — | 所有者 UID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 指定文档 ID(可选) | | `parentType` | `number` | — | 父文档类型 | | `parentId` | `ObjectId \| number \| string` | — | 父文档 ID | | `args` | `any` | — | 附加字段 | | **返回值** | `Promise` | | 新文档 ID | #### `get(domainId: string, docType: number, docId: ObjectId, projection?: any): Promise` 按组合键 `(domainId, docType, docId)` 获取单个文档。未找到时返回 `null`。接受可选字段投影。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `projection` | `any` | — | 字段投影 | | **返回值** | `Promise` | | | #### `getMulti(domainId: string, docType: number, query?: Filter, projection?: any): FindCursor` 返回匹配查询的多文档 `FindCursor`。接受可选过滤器和投影。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `query` | `Filter` | — | 过滤条件 | | `projection` | `any` | — | 字段投影 | | **返回值** | `FindCursor` | | | #### `set(domainId: string, docType: number, docId: ObjectId, $set?: any, $unset?: any, $push?: any): Promise` 使用 `$set`、`$unset` 和/或 `$push` 操作符原子性更新文档。返回更新后的文档。更新前触发 `document/set` 总线事件。文档不存在时执行 upsert。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `$set` | `any` | — | 要设置的字段 | | `$unset` | `any` | — | 要移除的字段 | | `$push` | `any` | — | 要推入数组的元素 | | **返回值** | `Promise` | | 更新后的文档 | #### `inc(domainId: string, docType: number, docId: ObjectId, key: string, value: number): Promise` 原子性递增数值字段 `value`。返回更新后的文档(文档不存在时返回 `null`)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `key` | `string` | — | 要递增的字段名 | | `value` | `number` | — | 递增量 | | **返回值** | `Promise` | | 更新后的文档(文档不存在时返回 `null`) | #### `incAndSet(domainId: string, docType: number, docId: ObjectId, key: string, value: number, args: any): Promise` 在单次操作中原子性递增数值字段并设置附加字段。返回更新后的文档(文档不存在时返回 `null`)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `key` | `string` | — | 要递增的字段名 | | `value` | `number` | — | 递增量 | | `args` | `any` | — | 附加设置字段 | | **返回值** | `Promise` | | 更新后的文档(文档不存在时返回 `null`) | #### `count(domainId: string, docType: number, query?: Filter): Promise` 统计匹配过滤器的文档数量。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `query` | `Filter` | — | 过滤条件 | | **返回值** | `Promise` | | 匹配的文档数量 | #### `deleteOne(domainId: string, docType: number, docId: ObjectId): Promise<[DeleteResult, DeleteResult]>` 删除单个文档及其关联的状态记录。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | **返回值** | `Promise<[DeleteResult, DeleteResult]>` | | 文档删除结果和状态删除结果 | #### `deleteMulti(domainId: string, docType: number, query?: Filter): Promise` 删除匹配过滤器的多个文档。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `query` | `Filter` | — | 过滤条件 | | **返回值** | `Promise` | | | ### 子文档操作 这些方法操作文档内的数组字段(如回复、标签)。 #### `push(domainId: string, docType: number, docId: ObjectId, key: string, ...): Promise<[Doc, ObjectId]>` *(两个重载)* **对象形式**: `push(domainId, docType, docId, key, value)` —— 将对象推入数组字段。 **内容形式**: `push(domainId, docType, docId, key, content, owner, args?)` —— 推入带自动生成 `_id` 的新子文档。 返回 `[updatedDoc, subId]`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `key` | `string` | — | 数组字段名 | | `value` | `any` | — | 要推入的对象(对象形式) | | `content` | `string` | — | 子文档内容(内容形式) | | `owner` | `number` | — | 子文档所有者 UID(内容形式) | | `args` | `any` | — | 附加字段(内容形式) | | **返回值** | `Promise<[Doc, ObjectId]>` | | 更新后的文档和子文档 ID | #### `pull(domainId: string, docType: number, docId: ObjectId, setKey: string, contents: any): Promise` 从数组字段中移除匹配给定过滤器的子文档。返回更新后的文档。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `setKey` | `string` | — | 数组字段名 | | `contents` | `any` | — | 移除过滤器 | | **返回值** | `Promise` | | 更新后的文档 | #### `getSub(domainId: string, docType: number, docId: ObjectId, key: string, subId: ObjectId): Promise<[Doc | null, SubDoc | null]>` 从数组字段中按 `_id` 获取特定子文档。返回 `[parentDoc, subDoc]`,未找到时返回 `[null, null]`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `key` | `string` | — | 数组字段名 | | `subId` | `ObjectId` | — | 子文档 ID | | **返回值** | `Promise<[Doc \| null, SubDoc \| null]>` | | | #### `setSub(domainId: string, docType: number, docId: ObjectId, key: string, subId: ObjectId, args: any): Promise` 更新按 `_id` 标识的特定子文档字段。使用位置 `$` 操作符。返回更新后的父文档。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `key` | `string` | — | 数组字段名 | | `subId` | `ObjectId` | — | 子文档 ID | | `args` | `any` | — | 要设置的字段 | | **返回值** | `Promise` | | 更新后的父文档 | #### `deleteSub(domainId: string, docType: number, docId: ObjectId, key: string, subId: ObjectId | ObjectId[]): Promise` 从数组字段中按 `_id` 移除一个或多个子文档。接受单个 ID 或 ID 数组。返回更新后的文档。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `key` | `string` | — | 数组字段名 | | `subId` | `ObjectId \| ObjectId[]` | — | 子文档 ID(单个或数组) | | **返回值** | `Promise` | | 更新后的文档 | #### `addToSet(domainId: string, docType: number, docId: ObjectId, setKey: string, content: string): Promise` 仅在数组字段中不存在时添加字符串值(通过 `$addToSet` 去重)。返回更新后的文档。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `setKey` | `string` | — | 数组字段名 | | `content` | `string` | — | 要添加的值 | | **返回值** | `Promise` | | 更新后的文档 | ### 状态 CRUD 状态记录追踪每用户状态(分数、提交、报名),以 `(domainId, docType, docId, uid)` 为键。 #### `getStatus(domainId: string, docType: number, docId: ObjectId, uid: number): Promise` 获取单个状态记录。未找到时返回 `null`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `uid` | `number` | — | 用户 ID | | **返回值** | `Promise` | | | #### `getMultiStatus(domainId: string, docType: number, args: any): FindCursor` 返回匹配过滤器的状态记录 `FindCursor`(限定域范围)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `args` | `any` | — | 过滤条件 | | **返回值** | `FindCursor` | | | #### `getMultiStatusWithoutDomain(docType: number, args: any): FindCursor` 返回匹配过滤器的状态记录 `FindCursor`(跨域)。用于系统级查询。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `docType` | `number` | — | 文档类型常量 | | `args` | `any` | — | 过滤条件 | | **返回值** | `FindCursor` | | | #### `setStatus(domainId: string, docType: number, docId: ObjectId, uid: number, args: any, returnDocument?: 'before' | 'after'): Promise` 设置状态记录字段,不存在时 upsert。`returnDocument` 控制返回的文档是更新 `'before'` 还是 `'after'`(默认 `'after'`)。返回 `'before'` 时使用 `readConcern: 'majority'`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `uid` | `number` | — | 用户 ID | | `args` | `any` | — | 要设置的字段 | | `returnDocument` | `'before' \| 'after'` | `'after'` | 返回更新前还是更新后的文档 | | **返回值** | `Promise` | | | ```typescript // 设置用户对题目的状态 const status = await document.setStatus( 'system', // domainId document.TYPE_PROBLEM, // docType problemDocId, // docId 12345, // uid { score: 100, accept: true }, // args ); // 获取更新前的状态(用于检测变更) const before = await document.setStatus( 'system', document.TYPE_PROBLEM, problemDocId, 12345, { star: true }, 'before', // returnDocument ); ``` #### `setMultiStatus(domainId: string, docType: number, query: any, args: any): Promise` 批量更新匹配过滤器的多个状态记录。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `query` | `any` | — | 过滤条件 | | `args` | `any` | — | 要设置的字段 | | **返回值** | `Promise` | | | #### `countStatus(domainId: string, docType: number, query?: any): Promise` 统计匹配过滤器的状态记录数量。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `query` | `any` | — | 过滤条件 | | **返回值** | `Promise` | | | #### `deleteMultiStatus(domainId: string, docType: number, query?: any): Promise` 删除匹配过滤器的状态记录。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `query` | `any` | — | 过滤条件 | | **返回值** | `Promise` | | | #### `incStatus(domainId: string, docType: number, docId: ObjectId, uid: number, key: string, value: number): Promise` 原子性递增状态记录上的数值字段。不存在时 upsert。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `uid` | `number` | — | 用户 ID | | `key` | `string` | — | 要递增的字段名 | | `value` | `number` | — | 递增量 | | **返回值** | `Promise` | | | #### `setStatusIfCondition(domainId: string, docType: number, docId: ObjectId, uid: number, filter: any, args?: any, returnDocument?: 'before' | 'after'): Promise` 条件性设置状态字段 —— 仅在附加 `filter` 匹配时更新。出错时返回 `false`(捕获异常)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `uid` | `number` | — | 用户 ID | | `filter` | `any` | — | 额外过滤条件 | | `args` | `any` | — | 要设置的字段 | | `returnDocument` | `'before' \| 'after'` | `'after'` | 返回更新前还是更新后的文档 | | **返回值** | `Promise` | | 条件不满足时返回 `false` | #### `setIfNotStatus(domainId: string, docType: number, docId: ObjectId, uid: number, key: string, value: any, ifNot: any, args: any, returnDocument?: 'before' | 'after'): Promise` 仅当字段当前值不为 `ifNot` 时设置为 `value`。委托 `setStatusIfCondition` 使用 `$ne` 过滤器。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `uid` | `number` | — | 用户 ID | | `key` | `string` | — | 字段名 | | `value` | `any` | — | 目标值 | | `ifNot` | `any` | — | 排除的当前值 | | `args` | `any` | — | 附加设置字段 | | `returnDocument` | `'before' \| 'after'` | `'after'` | 返回更新前还是更新后的文档 | | **返回值** | `Promise` | | 条件不满足时返回 `false` | #### `cappedIncStatus(domainId: string, docType: number, docId: ObjectId, uid: number, key: string, value: number, minValue?: number, maxValue?: number, setPayload?: any): Promise` 原子性递增数值字段但限制在 `[minValue, maxValue]` 范围内(默认 `[-1, 1]`)。字段将超出上限时递增被静默跳过。可选择在同一操作中设置附加字段。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `uid` | `number` | — | 用户 ID | | `key` | `string` | — | 要递增的字段名 | | `value` | `number` | — | 递增量 | | `minValue` | `number` | `-1` | 最小值 | | `maxValue` | `number` | `1` | 最大值 | | `setPayload` | `any` | — | 同步设置的附加字段 | | **返回值** | `Promise` | | | ```typescript // 比赛签到:attendance 字段从 -1 → 0 → 1,上限为 1 const status = await document.cappedIncStatus( 'system', // domainId document.TYPE_CONTEST, // docType contestDocId, // docId 12345, // uid 'attendance', // key 1, // value -1, // minValue 1, // maxValue { attendAt: new Date() }, // setPayload ); ``` ### 基于修订号的状态操作 这些方法在状态记录上维护 `rev`(修订计数器)用于乐观并发控制。 #### `revInitStatus(domainId: string, docType: number, docId: ObjectId, uid: number): Promise` 初始化或递增状态记录上的 `rev` 计数器。不存在时 upsert。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `uid` | `number` | — | 用户 ID | | **返回值** | `Promise` | | | #### `revPushStatus(domainId: string, docType: number, docId: ObjectId, uid: number, key: string, value: any, id?: ObjectId): Promise` 将值推入带修订追踪的状态数组字段。如果已存在匹配 `id` 的元素,则替换而非推入。两种情况均递增 `rev`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `uid` | `number` | — | 用户 ID | | `key` | `string` | — | 数组字段名 | | `value` | `any` | — | 要推入/替换的值 | | `id` | `string` | `'_id'` | 子元素 ID 字段名(匹配则替换) | | **返回值** | `Promise` | | | ```typescript // 比赛排行榜:推入每题提交详情,带修订追踪 const status = await document.revPushStatus( 'system', // domainId document.TYPE_CONTEST, // docType contestDocId, // docId 12345, // uid 'detail', // key { pid: problemId, score: 100 }, // value detailId, // id(如果匹配则替换,否则推入) ); ``` #### `revSetStatus(domainId: string, docType: number, docId: ObjectId, uid: number, rev: number, args: any): Promise` 仅当当前 `rev` 匹配提供的值时设置状态字段,然后递增 `rev`。用于乐观锁。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docType` | `number` | — | 文档类型常量 | | `docId` | `ObjectId` | — | 文档 ID | | `uid` | `number` | — | 用户 ID | | `rev` | `number` | — | 期望的当前修订号 | | `args` | `any` | — | 要设置的字段 | | **返回值** | `Promise` | | | --- ## 属性 | 属性 | 类型 | 说明 | |------|------|------| | `coll` | `Collection` | MongoDB `document` 集合 —— 存储所有文档记录 | | `collStatus` | `Collection` | MongoDB `document.status` 集合 —— 存储每用户状态记录 | --- ## 备注 - `apply(ctx)` 注册数据库索引和 `domain/delete` 清理处理器。在应用启动时调用一次。 - 插入前触发 `document/add` 总线事件;更新前触发 `document/set` 总线事件。 - `push` 方法有两个重载:对象形式和内容形式。 ### DomainModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/domain.ts ```ts import { DomainModel } from 'hydrooj' ``` # DomainModel 域(租户/组织)模型,提供域 CRUD、用户角色成员管理、角色权限管理和加入设置。 `DomainModel` 是纯静态类。所有方法均通过类本身调用(如 `DomainModel.get(...)`)。 --- ## 类型导出 ### `DomainDoc` 定义在 `packages/hydrooj/src/interface.ts`: ```typescript interface DomainDoc extends Record { _id: string; // 域 ID owner: number; // 所有者 UID roles: Dictionary; // 角色名 → 权限 bigint 字符串 avatar: string; // 域头像 URL bulletin: string; // 域公告文本 _join?: any; // 加入设置(method, role, expire, code) host?: string[]; // 自定义 host 头 } ``` 注意:`DomainDoc` 继承 `Record`,因此允许任意附加字段。 --- ## 常量 ### 加入方式 | 常量 | 值 | 说明 | |------|-----|------| | `JOIN_METHOD_NONE` | `0` | 不允许任何用户加入此域 | | `JOIN_METHOD_ALL` | `1` | 允许任何用户加入此域 | | `JOIN_METHOD_CODE` | `2` | 允许任何用户通过邀请码加入 | ### 加入有效期 | 常量 | 值 | 说明 | |------|-----|------| | `JOIN_EXPIRATION_KEEP_CURRENT` | `0` | 保持当前有效期 | | `JOIN_EXPIRATION_UNLIMITED` | `-1` | 永不过期 | | _(数字键)_ | `3`, `24`, `72`, `168`, `720` | 3 小时 / 1 天 / 3 天 / 1 周 / 1 个月 | --- ## 方法 ### 域 CRUD #### `add(domainId: string, owner: number, name: string, bulletin: string): Promise` 创建新域,指定所有者、名称和公告;将所有者设为 `root` 角色。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `owner` | `number` | — | 所有者 UID | | `name` | `string` | — | 域名称 | | `bulletin` | `string` | — | 域公告文本 | | **返回值** | `Promise` | | | #### `get(domainId: string): Promise` 按 ID 获取域(对 `lower` 字段不区分大小写查找),带 LRU 缓存。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | **返回值** | `Promise` | | | #### `getByHost(host: string): Promise` 按 `host` 字段获取域,带 LRU 缓存(也缓存 `null` 未命中)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `host` | `string` | — | 自定义 host 头值 | | **返回值** | `Promise` | | | #### `getMulti(query?: Filter): Cursor` 获取匹配过滤器的域游标。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `query` | `Filter` | — | MongoDB 过滤条件 | | **返回值** | `Cursor` | | | #### `getList(domainIds: string[]): Promise>` 以域 ID 为键的字典形式获取多个域(内部使用 `get`,遵循缓存)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainIds` | `string[]` | — | 域 ID 数组 | | **返回值** | `Promise>` | | | #### `edit(domainId: string, $set: Partial): Promise` 按 ID 更新城字段;广播缓存失效。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `$set` | `Partial` | — | 要更新的字段 | | **返回值** | `Promise` | | | #### `inc(domainId: string, field: NumberKeys, n: number): Promise` 原子性递增域上的数值字段;广播缓存失效。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `field` | `NumberKeys` | — | 要递增的数值字段名 | | `n` | `number` | — | 递增量 | | **返回值** | `Promise` | | | #### `getPrefixSearch(prefix: string, limit?: number): Promise` 按 ID 或名称前缀搜索域(正则,不区分大小写);默认限制 50。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `prefix` | `string` | — | 搜索前缀 | | `limit` | `number` | `50` | 返回数量上限 | | **返回值** | `Promise` | | | #### `del(domainId: string): Promise` 删除域及所有用户关联;触发 `domain/delete` 事件并使缓存失效。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | **返回值** | `Promise` | | | ### 域用户管理 #### `countUser(domainId: string, role?: string): Promise` 统计域中的已加入用户,可选按角色过滤。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `role` | `string` | — | 可选角色过滤 | | **返回值** | `Promise` | | | #### `getDomainUser(domainId: string, udoc: { _id: number, priv: number }): Promise` 获取域用户记录,包含有效角色和计算后的 `perm`(考虑用户特权如 `PRIV_MANAGE_ALL_DOMAIN`)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `udoc` | `{ _id: number, priv: number }` | — | 用户文档(至少含 `_id` 和 `priv`) | | **返回值** | `Promise` | | | #### `getDomainUserMulti(domainId: string, uids: number[]): Cursor` 按 UID 获取多个域用户记录的游标。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `uids` | `number[]` | — | 用户 ID 数组 | | **返回值** | `Cursor` | | | #### `getDictUserByDomainId(uid: number): Promise>` 获取用户的所有已加入域用户记录,以 `domainId` 为键。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `uid` | `number` | — | 用户 ID | | **返回值** | `Promise>` | | | #### `setUserRole(domainId: string, uid: MaybeArray, role: string, autojoin?: boolean): Promise` 设置域中的用户角色;支持单个或批量 UID;可选自动加入。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `uid` | `MaybeArray` | — | 单个或批量用户 ID | | `role` | `string` | — | 目标角色名 | | `autojoin` | `boolean` | `false` | 是否自动加入域 | | **返回值** | `Promise` | | | #### `setJoin(domainId: string, uid: MaybeArray, join: boolean): Promise` 设置域中用户的加入状态。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `uid` | `MaybeArray` | — | 单个或批量用户 ID | | `join` | `boolean` | — | 加入状态 | | **返回值** | `Promise` | | | #### `setUserInDomain(domainId: string, uid: number, params: any): Promise` 设置域用户记录的指定字段(upsert)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `uid` | `number` | — | 用户 ID | | `params` | `any` | — | 要设置的字段 | | **返回值** | `Promise` | | | #### `updateUserInDomain(domainId: string, uid: number, update: any): Promise` 对域用户记录应用任意 MongoDB 更新(upsert)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `uid` | `number` | — | 用户 ID | | `update` | `any` | — | MongoDB 更新操作 | | **返回值** | `Promise` | | | #### `setMultiUserInDomain(domainId: string, query: any, params: any): Promise` 批量使用 `$set` 更新匹配查询的域用户(upsert)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `any` | — | 匹配条件 | | `params` | `any` | — | 要设置的字段 | | **返回值** | `Promise` | | | #### `getMultiUserInDomain(domainId: string, query?: any): Cursor` 获取匹配查询的域用户游标。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `any` | — | 匹配条件 | | **返回值** | `Cursor` | | | #### `incUserInDomain(domainId: string, uid: number, field: string, n: number = 1): Promise` 对域用户记录执行读-改-写递增数值字段;返回更新后的文档。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `uid` | `number` | — | 用户 ID | | `field` | `string` | — | 要递增的字段名 | | `n` | `number` | `1` | 递增量 | | **返回值** | `Promise` | | | ### 角色管理 #### `getRoles(domainId: string | DomainDoc, count?: boolean): Promise` 获取域的所有角色(内置 + 自定义),可选附带每角色的用户计数。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string \| DomainDoc` | — | 域 ID 或域文档 | | `count` | `boolean` | `false` | 是否附带每角色用户计数 | | **返回值** | `Promise` | | | #### `setRoles(domainId: string, roles: Dictionary): Promise` 设置多个角色及其权限(合并到已有角色中)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `roles` | `Dictionary` | — | 角色名 → 权限映射 | | **返回值** | `Promise` | | | #### `addRole(domainId: string, name: string, permission: bigint): Promise` 添加具有指定权限的新自定义角色。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `name` | `string` | — | 角色名 | | `permission` | `bigint` | — | 角色权限 | | **返回值** | `Promise` | | | #### `deleteRoles(domainId: string, roles: string[]): Promise` 删除角色并将所有受影响的用户重置为 `default` 角色。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `roles` | `string[]` | — | 要删除的角色名数组 | | **返回值** | `Promise` | | | ### 加入设置 #### `getJoinSettings(ddoc: DomainDoc, roles: string[]): any | null` 如果域允许加入且角色被允许,则获取加入设置;加入被禁用或已过期时返回 `null`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `ddoc` | `DomainDoc` | — | 域文档 | | `roles` | `string[]` | — | 允许加入的角色列表 | | **返回值** | `any \| null` | | | --- ## 属性 | 属性 | 类型 | 说明 | |------|------|------| | `coll` | `Collection` | MongoDB `domain` 集合 | | `collUser` | `Collection` | MongoDB `domain.user` 集合 | --- ## 事件 | 事件 | 参数 | 说明 | |------|------|------| | `domain/create` | `ddoc: DomainDoc` | 域创建时触发(DB 插入前) | | `domain/before-get` | `query: Filter` | 域查询前触发;允许修改查询条件 | | `domain/get` | `ddoc: DomainDoc` | 域成功获取后触发 | | `domain/before-update` | `domainId, $set` | 域更新前触发 | | `domain/update` | `domainId, $set, ddoc` | 域更新后触发(含更新后的文档) | | `domain/delete` | `domainId` | 域删除后触发 | | `domain/delete-cache` | `domainId` | 跨集群广播使域缓存失效 | --- ## 备注 - 使用 `LRUCache`,最大 1000 条目,TTL 300000ms(5 分钟)。 - 缓存键:ID 查找使用 `id::{lower}`,host 查找使用 `host::{host}`。 - 缓存通过 `domain/delete-cache` 广播(跨集群)在编辑、递增、角色变更和删除时失效。 - `getByHost` 缓存 `null` 结果(未找到 host 对应的域)。 ### MessageModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/message.ts ```ts import { MessageModel } from 'hydrooj' ``` # MessageModel 消息模型,用于发送、查询和删除用户消息及系统通知。 `MessageModel` 是纯静态类。所有方法均通过类本身调用(如 `MessageModel.send(...)`)。 --- ## 类型导出 ### `MessageDoc` ```typescript interface MessageDoc { from: number; to: number | number[]; content: string; flag: number; } ``` --- ## 常量 | 常量 | 值 | 说明 | |------|-----|------| | `FLAG_UNREAD` | `1` | 消息未读 | | `FLAG_ALERT` | `2` | 消息为警报 | | `FLAG_RICHTEXT` | `4` | 消息包含富文本 | | `FLAG_INFO` | `8` | 信息性消息 | | `FLAG_I18N` | `16` | 内容为 i18n 键 | 标志为位掩码值 —— 使用按位 OR 组合(如 `FLAG_INFO | FLAG_I18N`)。 --- ## 方法 ### 发送 #### `send(from: number, to: number | number[], content: string, flag?: number): Promise` 从用户 `from` 向一个或多个接收者发送消息。默认为 `FLAG_UNREAD`。广播 `user/message` 事件并递增接收者的 `unreadMsg` 计数。返回消息文档(如果 `to` 为空则不含 `_id`)。 **@ArgMethod** | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `from` | `number` | — | 发送者 UID | | `to` | `number \| number[]` | — | 接收者 UID 或 UID 数组 | | `content` | `string` | — | 消息内容 | | `flag` | `number` | `FLAG_UNREAD` | 标志位掩码 | | **返回值** | `Promise` | | 消息文档 | #### `sendInfo(to: number, content: string): Promise` 向单个用户发送临时信息/i18n 通知。组合 `FLAG_INFO | FLAG_I18N`。通过 `user/message` 广播,但**不**持久化到数据库。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `to` | `number` | — | 接收者 UID | | `content` | `string` | — | i18n 键或消息内容 | | **返回值** | `Promise` | | | #### `sendNotification(message: string, ...args: any[]): Promise` 向所有具有 `PRIV_VIEW_SYSTEM_NOTIFICATION` 的用户发送翻译后的通知。`message` 通过 `app.i18n` 使用每个接收者的 `viewLang` 翻译,`args` 传递给 `format()`。消息以 `FLAG_RICHTEXT` 发送。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `message` | `string` | — | i18n 消息键 | | `args` | `any[]` | — | 传递给 `format()` 的参数 | | **返回值** | `Promise` | | 每个接收者的发送结果 | ### 查询 #### `get(_id: ObjectId): Promise` 按 `_id` 获取单个消息。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `_id` | `ObjectId` | — | 消息 ID | | **返回值** | `Promise` | | | #### `getByUser(uid: number): Promise` 获取指定用户发送或接收的最多 1000 条消息,按 `_id` 降序排列(最新在前)。 **@ArgMethod** | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `uid` | `number` | — | 用户 UID | | **返回值** | `Promise` | | 最多 1000 条消息 | #### `getMany(query: Filter, sort: any, page: number, limit: number): Promise` 带自定义过滤和排序的分页消息列表。应用 `skip((page-1)*limit)`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `query` | `Filter` | — | MongoDB 过滤条件 | | `sort` | `any` | — | 排序条件 | | `page` | `number` | — | 页码(从 1 开始) | | `limit` | `number` | — | 每页数量 | | **返回值** | `Promise` | | | #### `getMulti(uid: number): Cursor` 获取指定用户发送或接收的所有消息的 MongoDB 游标。无排序或限制 —— 调用方控制迭代。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `uid` | `number` | — | 用户 UID | | **返回值** | `Cursor` | | | ### 删除 #### `del(_id: ObjectId): Promise` 按 `_id` 删除单个消息。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `_id` | `ObjectId` | — | 消息 ID | | **返回值** | `Promise` | | | ### 统计 #### `count(query?: Filter): Promise` 统计匹配给定过滤器的消息数量。默认为所有消息。 **@ArgMethod** | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `query` | `Filter` | — | 过滤条件 | | **返回值** | `Promise` | | | --- ## 属性 | 属性 | 类型 | 说明 | |------|------|------| | `coll` | `Collection` | MongoDB `message` 集合 | --- ## 备注 - `send` 同时持久化到 MongoDB 并实时广播 `user/message`。 - `sendInfo` 是即发即弃 —— 它广播但不写入数据库。 - `sendNotification` 用于系统级公告(如维护通知)。 - `getByUser` 限制结果为 1000 条;使用 `getMulti` 进行无限制的游标迭代。 - **索引**:`{ to: 1, _id: -1 }`(按接收者查找)、`{ from: 1, _id: -1 }`(按发送者查找)。 ### OauthModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/oauth.ts ```ts import { OauthModel } from 'hydrooj' ``` # OauthModel OAuth 提供商与账号关联模型,用于注册第三方登录提供商、查找关联账号以及管理平台到用户的映射。 与大多数模型不同,`OauthModel` 继承自 `Service`,通过 `ctx.oauth` 访问而非使用静态方法。 --- ## 类型导出 ### `OauthMap` ```typescript interface OauthMap { platform: string; // OAuth 平台名称(如 'github'、'google'、'mail') id: string; // 来自提供商的 openId uid: number; // 目标 Hydro 用户 ID } ``` ### `OAuthProvider` ```typescript interface OAuthProvider { text: string; // 显示标签 name: string; // 提供商标识符 icon?: string; // 图标 URL 或标识符 hidden?: boolean; // 若为 true,不在登录界面显示 get: (this: Handler) => Promise; // 发起 OAuth 流程 callback: (this: Handler, args: Record) => Promise; // 处理 OAuth 回调 canRegister?: boolean; // 该提供商是否允许新用户注册 lockUsername?: boolean; // 关联后用户名是否锁定 } ``` ### `OAuthUserResponse` ```typescript interface OAuthUserResponse { _id: string; // 外部用户 ID email: string; // 用户邮箱 avatar?: string; // 头像 URL bio?: string; // 用户简介 uname?: string[]; // 用户名候选列表 viewLang?: string; // 首选语言 set?: Record; // 需设置到用户文档的字段 setInDomain?: Record; // 需设置到域用户文档的字段 } ``` --- ## 方法 ### 查找 #### `get(platform: string, id: string): Promise` 查找与平台+openId 对关联的 Hydro 用户 ID。返回关联的 `uid`,若无映射则返回 `null`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `platform` | `string` | — | OAuth 平台名称(如 `'github'`) | | `id` | `string` | — | 来自提供商的 openId | | **返回值** | `Promise` | | 关联的用户 UID,或 `null` | ### 账号关联 #### `set(platform: string, id: string, uid: number): Promise` 创建或更新 OAuth 账号映射。使用 upsert 操作。返回 upsert 文档的 `uid`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `platform` | `string` | — | OAuth 平台名称 | | `id` | `string` | — | 来自提供商的 openId | | `uid` | `number` | — | Hydro 用户 UID | | **返回值** | `Promise` | | 关联的用户 UID | #### `unbind(platform: string, uid: number): Promise` 按平台和用户 ID 移除 OAuth 账号映射。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `platform` | `string` | — | OAuth 平台名称 | | `uid` | `number` | — | Hydro 用户 UID | | **返回值** | `Promise` | | | #### `list(uid: number): Promise` 列出用户的所有 OAuth 账号映射。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `uid` | `number` | — | Hydro 用户 UID | | **返回值** | `Promise` | | | ### 提供商注册 #### `provide(name: string, provider: OAuthProvider): Promise` 注册一个 OAuth 提供商。若同名提供商已存在则抛出异常。当服务上下文销毁时,提供商会自动清理。使用 `ctx.effect()` 注册并附带清理逻辑——上下文销毁时自动移除提供商。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `name` | `string` | — | 提供商标识符 | | `provider` | `OAuthProvider` | — | 提供商配置对象 | | **返回值** | `Promise` | | | ```typescript // 注册自定义 OAuth 提供商 ctx.oauth.provide('github', { text: 'GitHub', name: 'github', icon: 'github', async get() { /* 重定向到 GitHub OAuth */ }, async callback(args) { // 用 code 换取用户信息 return { _id: '...', email: '...', uname: ['...'] }; }, }); // 查找关联用户 const uid = await ctx.oauth.get('github', openId); // 关联账号 await ctx.oauth.set('github', openId, uid); // 列出用户的关联账号 const links = await ctx.oauth.list(uid); // 解除关联 await ctx.oauth.unbind('github', uid); ``` --- ## 属性 | 属性 | 类型 | 说明 | |------|------|------| | `coll` | `Collection` | MongoDB `oauth` 集合 | | `providers` | `Record` | 已注册的 OAuth 提供商,按名称索引 | --- ## 备注 - **索引**:在启动时通过 `[Context.init]` 创建——`{ platform: 1, id: 1 }`(唯一索引,每个 platform+openId 仅一条映射)、`{ uid: 1, platform: 1 }`(列出用户的关联账号)。 ### OpcountModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/opcount.ts ```ts import { OpcountModel } from 'hydrooj' ``` # OpcountModel 频率限制模型,用于跟踪和强制执行时间窗口内的操作计数。 `OpcountModel` 是一个纯模块,导出函数而非类。所有方法直接调用(如 `OpcountModel.inc(...)`)。 --- ## 方法 ### 频率限制 #### `inc(op: string, ident: string, periodSecs: number, maxOperations: number): Promise` 在当前时间窗口内,对指定操作类型和标识符的操作计数器进行原子递增。返回新计数值。若已达到限制则抛出 `OpcountExceededError`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `op` | `string` | — | 操作类型标识符(如 `"login"`、`"submit"`) | | `ident` | `string` | — | 调用者的唯一标识(如用户 ID、IP 地址) | | `periodSecs` | `number` | — | 频率限制窗口的时长(秒) | | `maxOperations` | `number` | — | 一个窗口内允许的最大操作次数 | | **返回值** | `Promise` | | 新计数值 | ### 生命周期 #### `apply(): Promise` 在启动时创建所需的 MongoDB 索引。在应用初始化期间调用一次。 --- ## 备注 - 时间窗口对齐到固定边界(基于 `periodSecs`),而非从首次请求开始滑动。 - 当 upsert 命中唯一约束(计数器已存在且达到上限)时,捕获 duplicate key error 并以 `OpcountExceededError` 重新抛出。 - `OpcountExceededError` 继承自 `ForbiddenError`,消息为:*"Too frequent operations of {op} (limit: {maxOperations} operations in {periodSecs} seconds)."* - `apply()` 创建以下索引:`{ expireAt: -1 }`(TTL,自动删除过期窗口)、`{ op: 1, ident: 1, expireAt: 1 }`(unique,确保每个操作/标识/窗口仅一个计数器)。 ### OplogModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/oplog.ts ```ts import { OplogModel } from 'hydrooj' ``` # OplogModel 操作日志模型,用于记录和查询审计日志条目。 `OplogModel` 是一个纯模块,导出函数而非类。所有方法直接调用(如 `OplogModel.log(...)`)。 --- ## 方法 ### 日志记录 #### `log(handler: Handler | ConnectionHandler, type: string, data: any): Promise` 从 HTTP 处理器上下文记录一条操作日志。自动捕获请求元数据(域、user-agent、referer、路径、IP、操作者)。在插入前触发 `oplog/log` 总线事件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `handler` | `Handler \| ConnectionHandler` | — | 请求处理器,用于提取请求上下文 | | `type` | `string` | — | 操作类型标识符(如 `"problem.create"`、`"user.login"`) | | `data` | `any` | — | 与日志条目一起存储的附加数据 | | **返回值** | `Promise` | | 新插入文档的 `_id` | #### `add(data: Partial & { type: string }): Promise` 插入一条原始操作日志,不带请求上下文。适用于系统级或后台任务的日志记录。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `data` | `Partial & { type: string }` | — | 部分操作日志文档。必须包含 `type`。若提供 `_id`,则重映射为 `id` | | **返回值** | `Promise` | | 新插入文档的 `_id` | ### 查询 #### `get(id: ObjectId): Promise` 通过 `_id` 获取单条操作日志。未找到则返回 `null`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `id` | `ObjectId` | — | 日志条目 `_id` | | **返回值** | `Promise \| null` | | | --- ## 属性 | 属性 | 类型 | 说明 | |------|------|------| | `coll` | `Collection` | MongoDB `oplog` 集合,用于自定义查询 | --- ## 备注 - `log()` 在存储前会对处理器参数进行清洗:去除 `password`/`verifyPassword` 字段及以 `__` 开头的键,并将键名中的 `$` 和 `.` 字符替换为 `_`。 - `log()` 通过 `bus.parallel()` 在数据库插入前触发 `oplog/log` 总线事件,允许插件响应或增强日志条目。 - `add()` **不会**自动填充请求元数据(时间、domainId、操作者等)——调用方需按需手动提供。 ### ProblemModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/problem.ts ```ts import { ProblemModel } from 'hydrooj' ``` # ProblemModel 题目管理模型,提供增删改查操作、测试数据/附件管理、导入导出以及每用户状态跟踪。 `ProblemModel` 是一个纯静态类。所有方法直接在类上调用(如 `ProblemModel.get(...)`)。它封装了 `document` 子系统,文档类型为 `TYPE_PROBLEM`。 --- ## 类型导出 ### `ProblemDoc` 主要题目文档类型。继承 `Document`(提供 `_id`、`docId`、`docType`、`domainId`、`owner`、`maintainer?`)。通过 `interface.ts` 中的模块扩充声明的额外字段: | 字段 | 类型 | 说明 | |------|------|------| | `pid` | `string` | 题目标识符(如 `"A"`、`"abc-123"`) | | `title` | `string` | 题目标题 | | `content` | `string` | 题面(纯文本、HTML 或多语言 JSON) | | `nSubmit` | `number` | 总提交数 | | `nAccept` | `number` | 总通过数 | | `tag` | `string[]` | 标签/分类 | | `data` | `FileInfo[]` | 测试数据文件元数据 | | `additional_file` | `FileInfo[]` | 附加文件元数据 | | `hidden` | `boolean?` | 是否隐藏题目 | | `html` | `boolean?` | 内容是否为原始 HTML | | `stats` | `any?` | 统计对象 | | `difficulty` | `number?` | 难度等级 | | `sort` | `string?` | 用于排序的排序键 | | `config` | `string?` | 评测配置(YAML 字符串) | | `reference` | `{ domainId: string, pid: number }?` | 源题目引用(用于复制的题目) | ### `ProblemDict` ```typescript type ProblemDict = NumericDictionary ``` 以 `docId`(数字)和 `pid`(字符串)为键的字典。 ### `ProblemStatusDoc` 每用户题目状态文档。继承 `StatusDocBase`。 | 字段 | 类型 | 说明 | |------|------|------| | `docId` | `number` | 题目 docId | | `docType` | `10` | 始终为 `TYPE_PROBLEM` | | `uid` | `number` | 用户 ID | | `rid` | `ObjectId?` | 最佳/最新提交的记录 ID | | `score` | `number?` | 最佳分数 | | `status` | `number?` | 最佳状态码 | | `star` | `boolean?` | 用户是否收藏了该题目 | ### `Field` ```typescript type Field = keyof ProblemDoc; ``` 所有 ProblemDoc 字段名的联合类型。用于投影数组。 --- ## 常量 ### 投影常量 为常见查询模式预构建的字段投影数组。 | 常量 | 字段 | 用途 | |------|------|------| | `PROJECTION_LIST` | `_id`, `domainId`, `docType`, `docId`, `pid`, `owner`, `title`, `nSubmit`, `nAccept`, `difficulty`, `tag`, `hidden`, `stats` | 题目列表页 | | `PROJECTION_CONTEST_LIST` | `PROJECTION_BASE` + `config` | 比赛题目列表 | | `PROJECTION_CONTEST_DETAIL` | `PROJECTION_CONTEST_LIST` + `content`, `html`, `data`, `additional_file`, `reference`, `maintainer` | 比赛题目详情 | | `PROJECTION_PUBLIC` | `PROJECTION_LIST` + `content`, `html`, `data`, `config`, `additional_file`, `reference`, `maintainer` | 完整公开题目视图 | --- ## 方法 ### 增删改查 #### `add(domainId: string, pid: string = '', title: string, content: string, owner: number, tag?: string[], meta?: ProblemCreateOptions): Promise` 创建新题目,`docId` 自动递增。触发 `problem/before-add` 和 `problem/add` 事件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `string` | `''` | 题目标识符 | | `title` | `string` | — | 题目标题 | | `content` | `string` | — | 题面 | | `owner` | `number` | — | 拥有者用户 ID | | `tag` | `string[]` | `[]` | 标签 | | `meta` | `ProblemCreateOptions` | `{}` | 附加选项 | | **返回值** | `Promise` | | 新 `docId` | ```typescript // 创建新题目 const docId = await ProblemModel.add( 'system', // domainId 'A', // pid '两数之和', // title '给定两个整数 a 和 b', // content uid, // owner ['数学', '入门'], // tag ); // 创建隐藏题目 const hiddenDocId = await ProblemModel.add( 'system', 'B', '隐藏题', '题面内容', uid, [], { hidden: true }, ); ``` #### `addWithId(domainId: string, docId: number, pid: string = '', title: string, content: string, owner: number, tag?: string[], meta?: ProblemCreateOptions): Promise` 使用指定 `docId` 创建题目。由 `add` 和导入逻辑内部使用。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docId` | `number` | — | 指定的 `docId` | | `pid` | `string` | `''` | 题目标识符 | | `title` | `string` | — | 题目标题 | | `content` | `string` | — | 题面 | | `owner` | `number` | — | 拥有者用户 ID | | `tag` | `string[]` | `[]` | 标签 | | `meta` | `ProblemCreateOptions` | `{}` | 附加选项 | | **返回值** | `Promise` | | 新 `docId` | #### `get(domainId: string, pid: string | number, projection?: Projection, rawConfig?: boolean): Promise` 通过数字 `docId` 或字符串 `pid` 获取单个题目。除非 `rawConfig` 为 `true`,否则自动解析评测配置。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `string \| number` | — | 题目 ID 或 pid | | `projection` | `Projection` | `PROJECTION_PUBLIC` | 要返回的字段 | | `rawConfig` | `boolean` | `false` | 跳过配置解析 | | **返回值** | `Promise` | | | #### `getMulti(domainId: string, query: Filter, projection?: Field[]): MongoDB.Cursor` 获取查询多个题目的 MongoDB 游标,按 `sort` 字段排序。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `Filter` | — | MongoDB 过滤器 | | `projection` | `Field[]` | `PROJECTION_LIST` | 要返回的字段 | | **返回值** | `MongoDB.Cursor` | | | #### `list(domainId: string, query: Filter, page: number, pageSize: number, projection?: Field[]): Promise<[ProblemDoc[], number, number]>` *(已弃用)* 分页题目列表。返回 `[docs, count, page]`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `Filter` | — | MongoDB 过滤器 | | `page` | `number` | — | 页码 | | `pageSize` | `number` | — | 每页数量 | | `projection` | `Field[]` | `PROJECTION_LIST` | 要返回的字段 | | **返回值** | `Promise<[ProblemDoc[], number, number]>` | | `[docs, count, page]` | #### `edit(domainId: string, _id: number, $set: Partial): Promise` 更新题目字段。`pid` 变更时重新计算 `sort` 键。触发 `problem/before-edit` 和 `problem/edit` 事件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `_id` | `number` | — | 题目 `docId` | | `$set` | `Partial` | — | 要更新的字段 | | **返回值** | `Promise` | | 更新后的文档 | #### `del(domainId: string, docId: number): Promise` 删除题目、其状态和关联的存储文件。触发 `problem/before-del` 和 `problem/delete` 事件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docId` | `number` | — | 题目 `docId` | | **返回值** | `Promise` | | 是否有内容被删除 | #### `count(domainId: string, query: Filter): Promise` 统计匹配过滤条件的题目数量。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `Filter` | — | MongoDB 过滤器 | | **返回值** | `Promise` | | | #### `copy(domainId: string, _id: number, target: string, pid?: string, hidden?: boolean): Promise` 将题目复制到另一个域,创建指向原始题目的引用链接。返回新题目的 `docId`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 源域 ID | | `_id` | `number` | — | 题目 `docId` | | `target` | `string` | — | 目标域 ID | | `pid` | `string` | — | 目标域中的题目 PID | | `hidden` | `boolean` | — | 是否在目标域中隐藏 | #### `random(domainId: string, query: Filter): Promise` 获取匹配过滤条件的随机题目 `pid` 或 `docId`。未找到则返回 `null`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `Filter` | — | MongoDB 过滤器 | | **返回值** | `Promise` | | | ### 批量查找 #### `getList(domainId: string, pids: number[], canViewHidden?: number | boolean, doThrow?: boolean, projection?: Field[], indexByDocIdOnly?: boolean): Promise` 获取多个题目作为 `ProblemDict`。解析引用、解析配置,可选对缺失题目抛出异常。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pids` | `number[]` | — | `docId` 数组 | | `canViewHidden` | `number \| boolean` | `false` | UID(检查拥有者/维护者关系)或 `true` 跳过检查 | | `doThrow` | `boolean` | `true` | 题目缺失时抛出异常 | | `projection` | `Field[]` | `PROJECTION_PUBLIC` | 要返回的字段 | | `indexByDocIdOnly` | `boolean` | `false` | 仅按 `docId` 索引,跳过 `pid` 键 | | **返回值** | `Promise` | | | ### 状态跟踪 #### `getStatus(domainId: string, docId: number, uid: number): Promise` 获取指定用户在特定题目上的状态记录。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `docId` | `number` | — | 题目 `docId` | | `uid` | `number` | — | 用户 ID | | **返回值** | `Promise` | | | #### `getMultiStatus(domainId: string, query: Filter): MongoDB.Cursor` 获取查询题目状态文档的游标。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `Filter` | — | MongoDB 过滤器 | | **返回值** | `MongoDB.Cursor` | | | #### `getListStatus(domainId: string, uid: number, pids: number[]): Promise>` 获取多个题目的状态记录,以 `docId` 为键的字典。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `uid` | `number` | — | 用户 ID | | `pids` | `number[]` | — | `docId` 数组 | | **返回值** | `Promise>` | | | #### `updateStatus(domainId: string, pid: number, uid: number, rid: ObjectId, status: number, score: number): Promise` 更新用户在题目上的状态。仅在新状态更优时更新(通过始终优先)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 `docId` | | `uid` | `number` | — | 用户 ID | | `rid` | `ObjectId` | — | 记录 ID | | `status` | `number` | — | 状态码 | | `score` | `number` | — | 分数 | | **返回值** | `Promise` | | 状态是否被更新 | #### `incStatus(domainId: string, pid: number, uid: number, key: NumberKeys, count: number): Promise` 递增用户题目状态上的数字字段。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 `docId` | | `uid` | `number` | — | 用户 ID | | `key` | `NumberKeys` | — | 要递增的字段名 | | `count` | `number` | — | 递增量 | #### `setStar(domainId: string, pid: number, uid: number, star: boolean): Promise` 设置或取消用户题目状态上的收藏标记。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 `docId` | | `uid` | `number` | — | 用户 ID | | `star` | `boolean` | — | `true` 收藏,`false` 取消收藏 | ### 测试数据管理 #### `addTestdata(domainId: string, pid: number, name: string, f: Readable | Buffer | string, operator?: number): Promise` 上传测试数据文件。更新题目文档中的 `data` 数组。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 `docId` | | `name` | `string` | — | 文件名 | | `f` | `Readable \| Buffer \| string` | — | 文件内容或路径 | | `operator` | `number` | `1` | 操作者用户 ID | #### `renameTestdata(domainId: string, pid: number, file: string, newName: string, operator?: number): Promise` 在存储和文档元数据中重命名测试数据文件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 `docId` | | `file` | `string` | — | 原文件名 | | `newName` | `string` | — | 新文件名 | | `operator` | `number` | `1` | 操作者用户 ID | #### `delTestdata(domainId: string, pid: number, name: string | string[], operator?: number): Promise` 删除一个或多个测试数据文件。`name` 可以是单个字符串或数组。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 `docId` | | `name` | `string \| string[]` | — | 要删除的文件名 | | `operator` | `number` | `1` | 操作者用户 ID | ### 附加文件管理 #### `addAdditionalFile(domainId: string, pid: number, name: string, f: Readable | Buffer | string, operator?: number, skipUpload?: boolean): Promise` 上传附加文件(如附件)。类似 `addTestdata`,但操作 `additional_file` 数组。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 `docId` | | `name` | `string` | — | 文件名 | | `f` | `Readable \| Buffer \| string` | — | 文件内容或路径 | | `operator` | `number` | `1` | 操作者用户 ID | | `skipUpload` | `boolean` | `false` | 跳过存储上传(仅元数据) | #### `renameAdditionalFile(domainId: string, pid: number, file: string, newName: string, operator?: number): Promise` 重命名附加文件。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 `docId` | | `file` | `string` | — | 原文件名 | | `newName` | `string` | — | 新文件名 | | `operator` | `number` | `1` | 操作者用户 ID | #### `delAdditionalFile(domainId: string, pid: number, name: string | string[], operator?: number): Promise` 删除一个或多个附加文件。`name` 接受 `string | string[]`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 `docId` | | `name` | `string \| string[]` | — | 要删除的文件名 | | `operator` | `number` | `1` | 操作者用户 ID | ### 子文档辅助 #### `push(domainId: string, _id: number, key: ArrayKeys, value: ProblemDoc[typeof key][0]): Promise<[Doc, ObjectId]>` 向数组字段(`data` 或 `additional_file`)追加元素。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `_id` | `number` | — | 题目 `docId` | | `key` | `ArrayKeys` | — | 数组字段名 | | `value` | `ProblemDoc[typeof key][0]` | — | 要追加的元素 | | **返回值** | `Promise<[Doc, ObjectId]>` | | 更新后的文档和新元素 ID | #### `pull(domainId: string, pid: number, key: ArrayKeys, values: ProblemDoc[typeof key][0][]): Promise` 按值从数组字段中移除元素。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 `docId` | | `key` | `ArrayKeys` | — | 数组字段名 | | `values` | `ProblemDoc[typeof key][0][]` | — | 要移除的值 | | **返回值** | `Promise` | | 更新后的文档 | #### `inc(domainId: string, _id: number, field: NumberKeys | string, n: number): Promise` 递增数字字段(如 `nSubmit`、`nAccept`)。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `_id` | `number` | — | 题目 `docId` | | `field` | `NumberKeys \| string` | — | 要递增的字段名 | | `n` | `number` | — | 递增量(负数为递减) | | **返回值** | `Promise` | | 更新后的文档 | ### 权限检查 #### `canViewBy(pdoc: ProblemDoc, udoc: User): boolean` 检查用户是否可以查看题目。若用户拥有 `PERM_VIEW_PROBLEM` 且拥有/维护该题目,或拥有 `PERM_VIEW_PROBLEM_HIDDEN`,或题目未隐藏,则返回 `true`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `pdoc` | `ProblemDoc` | — | 题目文档 | | `udoc` | `User` | — | 用户文档 | | **返回值** | `boolean` | | | ### 导入 / 导出 #### `import(domainId: string, filepath: string, options?: ProblemImportOptions): Promise` 从 ZIP 归档或目录导入题目。支持 Hydro、ICPC 和 DOMjudge 包格式。触发进度回调并处理配置合并。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 目标域 | | `filepath` | `string` | — | `.zip` 文件或目录路径 | | `options.preferredPrefix` | `string?` | — | 导入时替换 PID 前缀 | | `options.progress` | `Function?` | — | 进度回调 | | `options.override` | `boolean` | `false` | 覆盖已有题目 | | `options.operator` | `number` | `1` | 操作者用户 ID | | `options.delSource` | `boolean?` | — | 导入后删除源文件 | | `options.hidden` | `boolean?` | — | 将导入的题目标记为隐藏 | ```typescript // 从 ZIP 文件导入题目 await ProblemModel.import('system', '/tmp/problems.zip'); // 导入时指定前缀和进度回调 await ProblemModel.import('system', '/tmp/problems.zip', { preferredPrefix: 'contest-', override: true, progress: (current, total) => { console.log(`导入进度: ${current}/${total}`); }, }); // 导入目录并标记为隐藏 await ProblemModel.import('system', '/data/problems/', { hidden: true, operator: uid, }); ``` #### `export(domainId: string, pidFilter?: string): Promise` 导出所有题目(或匹配 PID 正则过滤器的题目)为 ZIP 归档,保存到当前工作目录。输出文件路径打印到控制台。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pidFilter` | `string` | `''` | PID 正则过滤器(留空导出全部) | | **返回值** | `Promise` | | 无返回值 | ```typescript // 导出域内所有题目 const zipPath = await ProblemModel.export('system'); // 仅导出匹配前缀的题目 const partialPath = await ProblemModel.export('system', '^contest-'); // zipPath/partialPath 为生成的 ZIP 文件路径 ``` --- ## 属性 | 属性 | 类型 | 说明 | |------|------|------| | `default` | `ProblemDoc` | 模板 `ProblemDoc`,所有字段设为安全默认值 | | `deleted` | `ProblemDoc` | 哨兵 `ProblemDoc`,用作已删除题目的占位符 | --- ## 事件 以下事件在 ProblemModel 操作期间通过 `bus` 触发: | 事件 | 参数 | 触发时机 | |------|------|----------| | `problem/before-add` | `domainId, content, owner, docId, args` | 创建题目前 | | `problem/add` | `args, result` | 创建题目后 | | `problem/before-edit` | `$set, $unset` | 编辑题目前 | | `problem/edit` | `result` | 编辑题目后 | | `problem/before-del` | `domainId, docId` | 删除题目前 | | `problem/delete` | `domainId, docId` | 删除题目后 | | `problem/addTestdata` | `domainId, pid, name, payload` | 添加测试数据后 | | `problem/renameTestdata` | `domainId, pid, file, newName` | 重命名测试数据后 | | `problem/delTestdata` | `domainId, pid, names` | 删除测试数据后 | | `problem/addAdditionalFile` | `domainId, pid, name, payload` | 添加附加文件后 | | `problem/renameAdditionalFile` | `domainId, pid, file, newName` | 重命名附加文件后 | | `problem/delAdditionalFile` | `domainId, pid, names` | 删除附加文件后 | ### RecordModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/record.ts ```ts import { RecordModel } from 'hydrooj' ``` # RecordModel 评测记录模型,提供提交创建、评测任务分发、结果更新、重测/重置和提交统计。 `RecordModel` 是一个纯静态类。所有方法直接在类上调用(如 `RecordModel.get(...)`)。 --- ## 类型导出 ### `RecordDoc` 主要记录文档类型。在 `packages/hydrooj/src/interface.ts` 中定义为基于 `RecordPayload`(来自 `@hydrooj/common`)的映射类型: ```typescript type RecordDoc = { [K in keyof RecordPayload]: K extends 'hackTarget' | 'contest' ? ObjectId : RecordPayload[K]; } & { _id: ObjectId; notify?: boolean; }; ``` `RecordPayload`(继承 `RecordJudgeInfo`)中的关键字段: | 字段 | 类型 | 说明 | |------|------|------| | `domainId` | `string` | 记录所属的域 | | `pid` | `number` | 题目 ID | | `uid` | `number` | 提交者用户 ID | | `lang` | `string` | 提交语言 | | `code` | `string` | 提交的源代码 | | `status` | `number` | 评测状态(来自 `STATUS` 枚举) | | `score` | `number` | 总分 | | `time` | `number` | 总用时(毫秒) | | `memory` | `number` | 总内存(KB) | | `rejudged` | `boolean` | 是否为重测 | | `progress` | `number?` | 评测进度百分比 | | `source` | `string?` | 来源标识符 | | `contest` | `ObjectId?` | 比赛 ID(或预测试/生成的哨兵值) | | `input` | `string \| string[]?` | 预测试输入数据 | | `hackTarget` | `ObjectId?` | Hack 提交的目标记录 ID | | `files` | `Record?` | 附加文件 | | `judgeTexts` | `(string \| JudgeMessage)[]` | 评测输出消息 | | `compilerTexts` | `string[]` | 编译器输出消息 | | `testCases` | `Required[]` | 每个测试点的结果 | | `judger` | `number` | 评测者用户 ID | | `judgeAt` | `Date` | 评测时间戳 | | `subtasks` | `Record?` | 子任务结果 | ### `RecordStatDoc` 存储在 `record.stat` 集合中的统计文档,用于唯一提交/通过提交跟踪: | 字段 | 类型 | 说明 | |------|------|------| | `_id` | `ObjectId` | 与记录的 `_id` 相同 | | `domainId` | `string` | 域 ID | | `pid` | `number` | 题目 ID | | `uid` | `number` | 用户 ID | | `time` | `number` | 用时 | | `memory` | `number` | 内存 | | `length` | `number` | 代码长度 | | `lang` | `string` | 语言 | ### `RecordHistoryDoc` 记录重置重测时归档到 `record.history` 中的评测结果: | 字段 | 类型 | 说明 | |------|------|------| | `_id` | `ObjectId` | 历史条目 ID | | `rid` | `ObjectId` | 原始记录 ID | | *(继承自 `RecordJudgeInfo`)* | | `score`、`time`、`memory`、`status`、`judgeTexts`、`compilerTexts`、`testCases`、`subtasks`、`judger`、`judgeAt` | ### `JudgeMeta` 传递给评测任务的元数据: | 字段 | 类型 | 说明 | |------|------|------| | `problemOwner` | `number` | 题目拥有者 UID | | `hackRejudge?` | `string` | Hack 重测标识符 | | `rejudge?` | `boolean \| 'controlled'` | 重测模式 | | `type?` | `string` | 评测类型提示 | --- ## 属性 | 属性 | 类型 | 说明 | |------|------|------| | `coll` | `Collection` | MongoDB 集合 `record` | | `collStat` | `Collection` | MongoDB 集合 `record.stat` | | `collHistory` | `Collection` | MongoDB 集合 `record.history` | | `PROJECTION_LIST` | `(keyof RecordDoc)[]` | 列表视图中包含的字段(17 个字段) | | `STAT_QUERY` | `object` | 统计查询的排序方式,每个键映射为 `[降序排序, 升序排序]` 二元组(`time`、`memory`、`length`、`date`) | | `RECORD_PRETEST` | `ObjectId` | 预测试记录的哨兵 ID(`000...000`) | | `RECORD_GENERATE` | `ObjectId` | 生成记录的哨兵 ID(`000...001`) | --- ## 方法 ### 查找 #### `get(_id: ObjectId): Promise` 通过 ObjectId 获取单条记录。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `_id` | `ObjectId` | — | 记录 ID | | **返回值** | `Promise` | | | #### `get(domainId: string, _id: ObjectId): Promise` 通过 domainId 和 ObjectId 获取单条记录。若记录的 domainId 不匹配则返回 `null`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `_id` | `ObjectId` | — | 记录 ID | | **返回值** | `Promise` | | | #### `getMulti(domainId: string, query: any, options?: FindOptions): Cursor` 查询多条记录。自动按 `domainId` 限定范围。返回 MongoDB 游标。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `any` | — | MongoDB 查询过滤器 | | `options` | `FindOptions` | — | 查询选项(排序、投影等) | | **返回值** | `Cursor` | | 记录游标 | #### `getMultiStat(domainId: string, query: any, sortBy?: any): Cursor` 查询多条统计文档。默认按 `_id` 降序排序。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `any` | — | MongoDB 查询过滤器 | | `sortBy` | `any` | — | 排序方式 | | **返回值** | `Cursor` | | 统计文档游标 | #### `getList(domainId: string, rids: ObjectId[], fields?: (keyof RecordDoc)[]): Promise>>` 通过 ID 数组获取记录,返回以十六进制字符串 `_id` 为键的映射。对输入 ID 去重。可选择投影特定字段。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `rids` | `ObjectId[]` | — | 记录 ID 数组 | | `fields` | `(keyof RecordDoc)[]` | — | 投影字段列表 | | **返回值** | `Promise>>` | | 以十六进制 ID 为键的记录映射 | #### `count(domainId: string, query: any): Promise` 统计匹配查询的记录数,按 `domainId` 限定范围。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `query` | `any` | — | MongoDB 查询过滤器 | | **返回值** | `Promise` | | 匹配数量 | --- ### 统计 #### `stat(domainId?: string): Promise<{ d5min, d1h, day, week, month, year, total }>` 获取各时间窗口的提交计数:5 分钟、1 小时、1 天、1 周、1 月、1 年和总计。可按域限定范围。 使用 `@ArgMethod` 装饰器修饰。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID(省略则统计全部) | | **返回值** | `Promise<{ d5min, d1h, day, week, month, year, total }>` | | 各时间窗口计数 | --- ### 提交与评测 #### `add(domainId: string, pid: number, uid: number, lang: string, code: string, addTask: boolean, args?: any): Promise` 创建新的评测记录。插入到 `coll` 中并可选分发评测任务。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `pid` | `number` | — | 题目 ID | | `uid` | `number` | — | 提交者 UID | | `lang` | `string` | — | 语言标识符 | | `code` | `string` | — | 源代码 | | `addTask` | `boolean` | — | 是否立即分发评测任务 | | `args.type` | `'judge' \| 'rejudge' \| 'pretest' \| 'hack' \| 'generate'` | `'judge'` | 提交类型 | | `args.contest` | `ObjectId?` | — | 比赛 ID | | `args.input` | `string[]?` | — | 预测试输入数据 | | `args.files` | `Record?` | — | 附加文件 | | `args.hackTarget` | `ObjectId?` | — | Hack 的目标记录 | | `args.notify` | `boolean?` | — | 评测完成时是否发送通知 | | **返回值** | `Promise` | | 插入记录的 ObjectId | ```typescript // 标准提交 const rid = await RecordModel.add( 'system', 1001, session.uid, 'cpp', '#include \nint main() {}', true, ); // 预测试提交 const pretestRid = await RecordModel.add( 'system', 1001, session.uid, 'cpp', code, true, { type: 'pretest', input: ['1 2\n', '3 4\n'] }, ); // Hack 提交 const hackRid = await RecordModel.add( 'system', 1001, session.uid, 'cpp', hackInput, true, { type: 'hack', hackTarget: targetRid, contest: tid }, ); ``` #### `judge(domainId: string, rids: MaybeArray | RecordDoc, priority?: number, config?: ProblemConfigFile, meta?: Partial): Promise` 提交一条或多条记录进行评测。解析题目(跟随引用),删除这些记录的已有任务,并创建新的评测任务。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `rids` | `MaybeArray \| RecordDoc` | — | 记录 ID 或文档 | | `priority` | `number` | `0` | 任务优先级 | | `config` | `ProblemConfigFile` | `{}` | 覆盖评测配置 | | `meta` | `Partial` | `{}` | 评测元数据 | | **返回值** | `Promise` | | | ```typescript // 提交单条记录评测 await RecordModel.judge('system', rid); // 重测多条记录(带自定义优先级和元数据) await RecordModel.judge( 'system', [rid1, rid2, rid3], 1, {}, { rejudge: true, problemOwner: uid }, ); // 使用自定义评测配置重测 await RecordModel.judge( 'system', rid, 0, { timeLimit: 5000, memoryLimit: 512 }, { type: 'rejudge' }, ); ``` #### `submissionPriority(uid: number, base?: number): Promise` 计算用户的动态提交优先级。根据近期提交量和待处理任务降低优先级。用于限制高频提交者。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `uid` | `number` | — | 用户 ID | | `base` | `number` | — | 基础优先级 | | **返回值** | `Promise` | | 计算后的优先级 | --- ### 更新 #### `update(domainId: string, _id: ObjectId | ObjectId[], $set?: any, $push?: any, $unset?: any, $inc?: any): Promise` 更新单条或多条记录。接受 MongoDB 更新操作符(`$set`、`$push`、`$unset`、`$inc`)。当 `_id` 为数组时执行 `updateMany` 并返回 `null`;否则返回更新后的文档。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `_id` | `ObjectId \| ObjectId[]` | — | 记录 ID 或 ID 数组(数组时批量更新) | | `$set` | `any` | — | 要设置的字段 | | `$push` | `any` | — | 要追加的字段 | | `$unset` | `any` | — | 要删除的字段 | | `$inc` | `any` | — | 要增减的字段 | | **返回值** | `Promise` | | 更新后的文档(批量更新返回 `null`) | #### `updateMulti(domainId: string, $match: any, $set?: any, $push?: any, $unset?: any): Promise` 更新匹配过滤器的多条记录。返回修改的文档数量。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `$match` | `any` | — | MongoDB 匹配过滤器 | | `$set` | `any` | — | 要设置的字段 | | `$push` | `any` | — | 要追加的字段 | | `$unset` | `any` | — | 要删除的字段 | | **返回值** | `Promise` | | 修改的文档数量 | #### `reset(domainId: string, rid: ObjectId | ObjectId[], isRejudge: boolean): Promise` 重置一条或多条记录以进行重测。将当前评测结果归档到 `record.history`,将所有评测字段清除为默认值,并删除关联的统计条目和任务。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `domainId` | `string` | — | 域 ID | | `rid` | `ObjectId \| ObjectId[]` | — | 记录 ID 或 ID 数组 | | `isRejudge` | `boolean` | — | 是否为重测(`true` 时标记 `rejudged`) | | **返回值** | `Promise` | | 重置后的文档(批量重置返回 `null`) | ```typescript // 重测单条记录 await RecordModel.reset('system', rid, true); // 记录状态清除,旧结果归档到 record.history,随后可调用 judge 重新评测 await RecordModel.judge('system', rid, 0, {}, { rejudge: true }); // 批量重测某题目的所有记录 const rids = await RecordModel.getMulti('system', { pid: 1001 }).map(r => r._id).toArray(); await RecordModel.reset('system', rids, true); await RecordModel.judge('system', rids); ``` --- ## 事件 | 事件 | 参数 | 说明 | |------|------|------| | `record/change` | `RecordDoc` | 创建新记录时广播(通过 `add()`) | | `record/judge` | `rdoc: RecordDoc, updated: boolean` | 评测完成时触发;对通过的提交更新 `record.stat` 并发送通知 | ### ScheduleModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/schedule.ts ```ts import { ScheduleModel } from 'hydrooj' ``` # ScheduleModel 定时任务模型,用于创建、查询和删除延迟或周期性任务。 `ScheduleModel` 是一个纯静态类。所有方法直接在类上调用(如 `ScheduleModel.add(...)`)。 --- ## 类型导出 ### `Schedule` 定义在 `packages/hydrooj/src/interface.ts` 中: ```typescript interface Schedule { _id: ObjectId; type: string; subType?: string; executeAfter: Date; // interval is not explicitly declared — it is accessed at runtime via the // index signature below (e.g. res.interval as [number, moment.UnitOfTime]). [key: string]: any; } ``` 索引签名允许根据任务类型携带任意字段(如 `domainId`、自定义负载数据)。 --- ## 方法 ### 创建 #### `add(task: Partial & { type: string }): Promise` 插入一条新的定时任务。若省略 `executeAfter`,默认为 `new Date()`(立即执行)。返回插入文档的 `_id`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `task` | `Partial & { type: string }` | — | 任务定义。必须包含 `type` | | **返回值** | `Promise` | | 新文档的 `_id` | ### 查询 #### `get(_id: ObjectId): Promise` 通过 `_id` 查找单条定时任务。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `_id` | `ObjectId` | — | 任务 `_id` | | **返回值** | `Promise` | | | #### `count(query: Filter): Promise` 统计匹配给定过滤条件的文档数量。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `query` | `Filter` | — | MongoDB 过滤条件 | | **返回值** | `Promise` | | | #### `getFirst(query: Filter): Promise` 原子性地查找并删除匹配过滤条件中最早到期的任务(`executeAfter < now`)。若任务设有 `interval`,则自动重新调度——将 `executeAfter` 推进一个间隔周期。若无到期任务(或在 CI 环境中运行)则返回 `null`。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `query` | `Filter` | — | MongoDB 过滤条件 | | **返回值** | `Promise` | | | ```typescript // 消费到期任务 const task = await ScheduleModel.getFirst({ type: 'judge' }); if (task) { // 处理任务... // 若 task.interval 存在,已自动重新调度 } ``` ### 删除 #### `del(_id: ObjectId): Promise` 通过 `_id` 删除单条定时任务。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `_id` | `ObjectId` | — | 任务 `_id` | | **返回值** | `Promise` | | | #### `deleteMany(query: Filter): Promise` 删除匹配给定过滤条件的所有定时任务。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `query` | `Filter` | — | MongoDB 过滤条件 | | **返回值** | `Promise` | | | --- ## 属性 | 属性 | 类型 | 说明 | |------|------|------| | `coll` | `Collection` | MongoDB `schedule` 集合 | --- ## 事件 | 事件 | 参数 | 说明 | |------|------|------| | `domain/delete` | `domainId` | 清理被删除域的所有定时任务 | | `task/daily` | — | 插件运行每日维护逻辑的钩子 | | `task/daily/finish` | `pref` | 每日任务完成后触发,负载包含计时统计 | --- ## 备注 - `apply()` 函数注册了一个内置的 `task.daily` 工作处理器,用于运行清理(预测试/生成记录)、RP 重算、题目统计和可选的更新检查。 - 每日任务在首次启动时自动创建(次日凌晨 03:00),以 1 天为周期循环执行。 - `getFirst` 使用 `findOneAndDelete` 实现原子消费——并发工作者安全。 - 复合索引 `{ type: 1, subType: 1, executeAfter: -1 }` 用于按类型高效查找任务。 ### SettingModel > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/hydrooj/src/model/setting.ts ```ts import { SettingModel } from 'hydrooj' ``` # SettingModel 设置注册模型,用于声明用户级、域级和系统级设置。 `SettingModel` 是一个纯模块,导出常量和注册函数而非类。插件使用它将自定义设置注册到相应的分类中。 --- ## 类型导出 ### `SettingType` ```typescript type SettingType = "text" | "yaml" | "number" | "float" | "markdown" | "password" | "boolean" | "textarea" | [string, string][] | Record | "json" ``` 设置值类型的类型别名。 --- ## 常量 ### 标志常量 | 常量 | 值 | 说明 | |------|-----|------| | `FLAG_HIDDEN` | `1` | 在设置界面隐藏 | | `FLAG_DISABLED` | `2` | 显示但不可编辑 | | `FLAG_SECRET` | `4` | 密钥字段(如密码) | | `FLAG_PRO` | `8` | 需要 Hydro Pro | | `FLAG_PUBLIC` | `16` | 对非管理员用户可见 | | `FLAG_PRIVATE` | `32` | 仅对拥有者可见 | ### 集合常量 由注册函数填充的只读数组: | 常量 | 说明 | |------|------| | `PREFERENCE_SETTINGS` | 所有已注册的偏好设置 | | `ACCOUNT_SETTINGS` | 所有已注册的账户设置 | | `DOMAIN_SETTINGS` | 所有已注册的域设置 | | `DOMAIN_USER_SETTINGS` | 所有已注册的域用户设置 | | `SYSTEM_SETTINGS` | 所有已注册的系统设置 | | `SETTINGS` | 合并的偏好 + 账户设置数组(扁平) | 由注册函数填充的只读字典(按键索引): | 常量 | 说明 | |------|------| | `SETTINGS_BY_KEY` | 偏好 + 账户设置的查找映射 | | `DOMAIN_SETTINGS_BY_KEY` | 域设置的查找映射 | | `DOMAIN_USER_SETTINGS_BY_KEY` | 域用户设置的查找映射 | | `SYSTEM_SETTINGS_BY_KEY` | 系统设置的查找映射 | --- ## 方法 ### 设置工厂 #### `Setting(family: string, key: string, value?: any, type?: SettingType, name?: string, desc?: string, flag?: number, validation?: (val: any) => boolean): Setting` 创建一个设置描述符对象。这是所有注册函数使用的底层工厂函数。 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `family` | `string` | — | 用于 UI 分类的组/族名(如 `"setting_basic"`) | | `key` | `string` | — | 唯一设置键(如 `"pagination.problem"`) | | `value` | `any` | `null` | 默认值 | | `type` | `SettingType` | `"text"` | 输入类型。对象类型渲染为 `` 元素并渲染 React 下拉选择器。所有子类继承自 `AutoComplete` 基类,通过 `DOMAttachedObject` 模式挂载到页面 DOM。 ## 基类 ### AutoComplete Source: `packages/ui-default/components/autocomplete/index.tsx` 通用自动完成基类,将一个 `` DOM 元素包装为带下拉建议列表的选择器。内部使用 `@hydrooj/components` 的 `AutoComplete` React 组件进行渲染。 ```ts class AutoComplete = object, Multi extends boolean = boolean> extends DOMAttachedObject ``` - `DOMAttachKey`: `ucwAutoCompleteInstance` **构造参数:** - `$dom` — 要绑定的 jQuery DOM 元素(通常是 ``) - `options` — 配置选项(见 `AutoCompleteOptions`) **AutoCompleteOptions 接口:** | 属性 | 类型 | 说明 | |------|------|------| | `multi` | `boolean` | 是否允许多选 | | `defaultItems` | `string` | 默认选中的项(逗号分隔) | | `width` | `string` | 组件宽度 | | `height` | `string` | 组件高度 | | `classes` | `string` | 附加的 CSS 类名 | | `listStyle` | `any` | 下拉列表样式 | | `allowEmptyQuery` | `boolean` | 是否允许空查询触发搜索 | | `freeSolo` | `boolean` | 是否允许自由输入(不在列表中的值) | | `freeSoloConverter` | `any` | 自由输入值的转换函数 | | `onChange` | `(value) => any` | 选中值变化时的回调 | | `items` | `() => Promise` | 异步获取候选项列表 | | `render` | `() => string` | 自定义候选项渲染 | | `text` | `() => string` | 自定义选中项文本 | > **构造函数额外参数**:`component`(`React.ComponentType`,自定义 React 组件覆盖默认)和 `props`(`Record`,传递给 React 组件的额外属性)通过交叉类型传入,不属于 `AutoCompleteOptions` 接口。 **实例方法:** | 方法 | 说明 | |------|------| | `clear(clearValue?: boolean)` | 清除选中值或仅关闭下拉列表 | | `onChange(val)` | 设置值或注册变化监听器 | | `attach()` | 挂载 React 组件到 DOM | | `open()` | 打开下拉建议列表 | | `close()` | 关闭下拉建议列表 | | `value()` | 获取当前选中值:单选模式返回首个选中项对象(或 `null`),多选模式返回 `(string \| number)[]` | | `detach()` | 卸载组件并清理 DOM | | `focus()` | 聚焦输入框 | ## 子类组件 ### AssignSelectAutoComplete Source: `packages/ui-default/components/autocomplete/AssignSelectAutoComplete.tsx` 题目评测者/管理员分配选择器,默认多选模式。用于比赛或作业中将用户分配为评测者或管理员。 ```ts class AssignSelectAutoComplete extends AutoComplete ``` - `DOMAttachKey`: `ucwAssignSelectAutoCompleteInstance` - 默认 `multi: true`,`value()` 返回逗号分隔的选中键字符串 ### CustomSelectAutoComplete Source: `packages/ui-default/components/autocomplete/CustomSelectAutoComplete.tsx` 自定义数据源的下拉选择器,调用方通过 `data` 选项直接提供候选项列表。 ```ts class CustomSelectAutoComplete extends AutoComplete ``` - `DOMAttachKey`: `ucwCustomSelectAutoCompleteInstance` - 额外选项 `data: any[]` — 静态候选项数据 ### DomainSelectAutoComplete Source: `packages/ui-default/components/autocomplete/DomainSelectAutoComplete.tsx` 域名(站点)选择器,用于在多个 Hydro 站点/域之间切换选择。 ```ts class DomainSelectAutoComplete extends AutoComplete ``` - `DOMAttachKey`: `ucwDomainSelectAutoCompleteInstance` - 默认高度 `34px` ### ProblemSelectAutoComplete Source: `packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.tsx` 题目选择器,用于搜索和选择题库中的题目。 ```ts class ProblemSelectAutoComplete extends AutoComplete ``` - `DOMAttachKey`: `ucwProblemSelectAutoCompleteInstance` ### UserSelectAutoComplete Source: `packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx` 用户选择器,用于搜索和选择系统用户。 ```ts class UserSelectAutoComplete extends AutoComplete ``` - `DOMAttachKey`: `ucwUserSelectAutoCompleteInstance` - `value()` 在多选模式下返回 `number[]`(用户 ID 数组),单选模式返回首个选中用户对象(或 `null`) ### FileSelectAutoComplete Source: `packages/ui-default/components/autocomplete/FileSelectAutoComplete.tsx` 文件选择器,从给定文件列表中选择文件。 ```ts class FileSelectAutoComplete extends AutoComplete ``` - `DOMAttachKey`: `ucwFileSelectAutoCompleteInstance` - 额外选项 `data: { id: string; name: string }[]` — 文件列表 ### LanguageSelectAutoComplete Source: `packages/ui-default/components/autocomplete/LanguageSelectAutoComplete.tsx` 编程语言选择器,用于选择题目的提交语言。 ```ts class LanguageSelectAutoComplete extends AutoComplete ``` - `DOMAttachKey`: `ucwLanguageSelectAutoCompleteInstance` - 额外选项 `withAuto: boolean` — 是否包含"自动"选项 ### loadMonaco > Source: https://github.com/hydro-dev/Hydro/blob/master/packages/ui-default/components/monaco/loader.ts # loadMonaco 源码: [`packages/ui-default/components/monaco/loader.ts`](https://github.com/hydro-dev/Hydro/blob/master/packages/ui-default/components/monaco/loader.ts) 按需加载 Monaco 编辑器,支持可选语言特性和插件扩展。对并发的加载调用进行序列化以避免重复初始化。 ## 函数 ### load ```ts async function load(features?: string[]): Promise<{ monaco: typeof import('monaco-editor/esm/vs/editor/editor.api'); registerAction: (editor: monaco.editor.IStandaloneCodeEditor, model: monaco.editor.IModel, element?: HTMLElement) => Promise | null; customOptions: monaco.editor.IStandaloneDiffEditorConstructionOptions; renderMarkdown: typeof import('monaco-editor/esm/vs/base/browser/markdownRenderer').renderMarkdown; }> ``` 默认导出,从插件 API 以 `loadMonaco` 名称重新导出。加载 Monaco 编辑器及指定特性。特性仅加载一次;后续调用中已加载的特性会被跳过。 **参数:** - `features`(默认 `['markdown']`)— 要加载的特性名称数组。内置特性:`'markdown'`、`'typescript'`、`'yaml'`。插件贡献的特性通过 `getFeatures('monaco-{feat}')` 解析。 **行为:** 1. 通过内部 Promise 链序列化并发调用。 2. 首次调用时加载 i18n 语言数据(支持 zh、zh_TW、ko)。 3. 动态导入 `./index`(Monaco 编辑器模块)。 4. 遍历 `features`,通过内置加载器或外部插件加载器逐个加载。 5. 等待主题加载完成。 6. 返回 Monaco 实例及辅助工具。 **返回值:** - `monaco` — 完整的 Monaco 编辑器 API 模块。 - `registerAction` — 在编辑器实例上注册键盘快捷键(Ctrl+Enter 提交、Ctrl+Shift+P 命令面板、Alt+Shift+F 格式化)。同时在 markdown 模式下启用图片/zip 粘贴上传。 - `customOptions` — 从 `localStorage('editor.config')` 读取的持久化编辑器配置。可变;更改通过 `saveCustomOptions()` 保存。 - `renderMarkdown` — 从 `monaco-editor/esm/vs/base/browser/markdownRenderer` 重新导出。 ## 函数(已弃用) ### legacyLoadExternalModule ```ts async function legacyLoadExternalModule(target: string): Promise ``` **@deprecated** 通过向 `` 注入 `