From d317390d8ca6b1b76862681c743230424479a175 Mon Sep 17 00:00:00 2001 From: Zisu Zhang Date: Sat, 20 Apr 2024 11:19:06 +0800 Subject: [PATCH] feat: rule system improvements (#94) --- .yarn/versions/d253d201.yml | 3 + .../src/components/common/RulesEditor.vue | 41 +++-- .../components/contest/ProblemTabAdmin.vue | 2 +- .../frontend/src/components/utils/HelpBtn.vue | 13 ++ .../contest/[contestId]/admin/index.vue | 32 ++-- .../contest/[contestId]/admin/rule.vue | 8 +- .../org/[orgId]/problem/[problemId]/admin.vue | 3 + .../problem/[problemId]/admin/index.vue | 2 +- .../problem/[problemId]/admin/rule.vue | 14 ++ apps/server/src/db/contest.ts | 9 + apps/server/src/db/problem.ts | 28 +++- apps/server/src/routes/contest/scoped.ts | 39 ++++- .../src/routes/contest/solution/index.ts | 12 +- apps/server/src/routes/problem/admin.ts | 19 ++- apps/server/src/routes/problem/solution.ts | 35 +++- apps/server/src/schemas/common.ts | 16 +- apps/server/src/schemas/contest.ts | 10 +- apps/server/src/schemas/problem.ts | 6 + apps/server/src/utils/rule.ts | 4 +- docs/.vitepress/config.mts | 1 + docs/rule.md | 157 ++++++++++++++++++ 21 files changed, 383 insertions(+), 71 deletions(-) create mode 100644 .yarn/versions/d253d201.yml create mode 100644 apps/frontend/src/components/utils/HelpBtn.vue create mode 100644 apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin/rule.vue create mode 100644 docs/rule.md diff --git a/.yarn/versions/d253d201.yml b/.yarn/versions/d253d201.yml new file mode 100644 index 00000000..6a9eec9a --- /dev/null +++ b/.yarn/versions/d253d201.yml @@ -0,0 +1,3 @@ +releases: + "@aoi-js/frontend": minor + "@aoi-js/server": minor diff --git a/apps/frontend/src/components/common/RulesEditor.vue b/apps/frontend/src/components/common/RulesEditor.vue index 538659d1..50e4c4f2 100644 --- a/apps/frontend/src/components/common/RulesEditor.vue +++ b/apps/frontend/src/components/common/RulesEditor.vue @@ -1,25 +1,34 @@ diff --git a/apps/frontend/src/pages/org/[orgId]/contest/[contestId]/admin/index.vue b/apps/frontend/src/pages/org/[orgId]/contest/[contestId]/admin/index.vue index 00b50bb9..46fefb50 100644 --- a/apps/frontend/src/pages/org/[orgId]/contest/[contestId]/admin/index.vue +++ b/apps/frontend/src/pages/org/[orgId]/contest/[contestId]/admin/index.vue @@ -18,8 +18,8 @@ v-model="rejudgeOptions.problemId" :label="t('term.problem-id')" :rules="[isUUID, isNotEmpty]" - :append-icon="rejudgeOptions.problemId === undefined ? 'mdi-null' : 'mdi-delete'" - @click:append="rejudgeOptions.problemId = undefined" + :append-inner-icon="rejudgeOptions.problemId !== undefined && 'mdi-delete'" + @click:append-inner="rejudgeOptions.problemId = undefined" /> @@ -32,16 +32,16 @@ { title: 'Completed', value: 4 } ]" :label="t('term.state')" - :append-icon="rejudgeOptions.state === undefined ? 'mdi-null' : 'mdi-delete'" - @click:append="rejudgeOptions.state = undefined" + :append-inner-icon="rejudgeOptions.state !== undefined && 'mdi-delete'" + @click:append-inner="rejudgeOptions.state = undefined" /> @@ -49,8 +49,8 @@ v-model="rejudgeOptions.runnerId" :rules="[isUUID]" :label="t('term.runner-id')" - :append-icon="rejudgeOptions.runnerId === undefined ? 'mdi-null' : 'mdi-delete'" - @click:append="rejudgeOptions.runnerId = undefined" + :append-inner-icon="rejudgeOptions.runnerId !== undefined && 'mdi-delete'" + @click:append-inner="rejudgeOptions.runnerId = undefined" /> @@ -58,8 +58,8 @@ v-model.number="rejudgeOptions.scoreL" :rules="[(v) => v === undefined || v >= 0, (v) => v === undefined || v <= 100]" :label="t('min-score')" - :append-icon="rejudgeOptions.scoreL === undefined ? 'mdi-null' : 'mdi-delete'" - @click:append="rejudgeOptions.scoreL = undefined" + :append-inner-icon="rejudgeOptions.scoreL !== undefined && 'mdi-delete'" + @click:append-inner="rejudgeOptions.scoreL = undefined" /> @@ -67,24 +67,24 @@ v-model.number="rejudgeOptions.scoreR" :rules="[(v) => v === undefined || v >= 0, (v) => v === undefined || v <= 100]" :label="t('max-score')" - :append-icon="rejudgeOptions.scoreR === undefined ? 'mdi-null' : 'mdi-delete'" - @click:append="rejudgeOptions.scoreR = undefined" + :append-inner-icon="rejudgeOptions.scoreR !== undefined && 'mdi-delete'" + @click:append-inner="rejudgeOptions.scoreR = undefined" /> diff --git a/apps/frontend/src/pages/org/[orgId]/contest/[contestId]/admin/rule.vue b/apps/frontend/src/pages/org/[orgId]/contest/[contestId]/admin/rule.vue index 55386f96..486b5c65 100644 --- a/apps/frontend/src/pages/org/[orgId]/contest/[contestId]/admin/rule.vue +++ b/apps/frontend/src/pages/org/[orgId]/contest/[contestId]/admin/rule.vue @@ -1,12 +1,8 @@ diff --git a/apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin.vue b/apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin.vue index ae59d8a8..98a2d195 100644 --- a/apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin.vue +++ b/apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin.vue @@ -14,6 +14,9 @@ mdi-lock {{ t('term.access') }} + + {{ t('term.rules') }} + diff --git a/apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin/index.vue b/apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin/index.vue index 5789bb3d..983c2b79 100644 --- a/apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin/index.vue +++ b/apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin/index.vue @@ -75,7 +75,7 @@ const submitAllTask = useAsyncTask(async () => { return withMessage(t('msg.submit-all-success', { count: modifiedCount })) }) -const pull = ref(false) +const pull = ref(true) const rejudgeAllTask = useAsyncTask(async () => { const { modifiedCount } = await http .post(`problem/${props.problemId}/admin/rejudge-all`, { json: { pull: pull.value } }) diff --git a/apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin/rule.vue b/apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin/rule.vue new file mode 100644 index 00000000..c6653da2 --- /dev/null +++ b/apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin/rule.vue @@ -0,0 +1,14 @@ + + + diff --git a/apps/server/src/db/contest.ts b/apps/server/src/db/contest.ts index 10a66fa8..4ce94729 100644 --- a/apps/server/src/db/contest.ts +++ b/apps/server/src/db/contest.ts @@ -6,6 +6,7 @@ import { IContestProblemSettings, IContestRanklistSettings, IContestStage, + SContestParticipantRuleResult, SContestSolutionRuleResult } from '../schemas/contest.js' import { capabilityMask } from '../utils/capability.js' @@ -60,6 +61,12 @@ export interface IContestRanklist { settings: IContestRanklistSettings } +export interface IContestParticipantRuleCtx { + contest: IContest + currentStage: IContestStage + user: IUser +} + export interface IContestSolutionRuleCtx { contest: IContest currentStage: IContestStage @@ -69,6 +76,7 @@ export interface IContestSolutionRuleCtx { } export const contestRuleSchemas = { + participant: SContestParticipantRuleResult, solution: SContestSolutionRuleResult } @@ -96,6 +104,7 @@ export interface IContest rules?: RulesFromSchemas< typeof contestRuleSchemas, { + participant: IContestParticipantRuleCtx solution: IContestSolutionRuleCtx } > diff --git a/apps/server/src/db/problem.ts b/apps/server/src/db/problem.ts index 2ccd41a1..304f11cb 100644 --- a/apps/server/src/db/problem.ts +++ b/apps/server/src/db/problem.ts @@ -2,10 +2,17 @@ import { ProblemConfig } from '@aoi-js/common' import { fastifyPlugin } from 'fastify-plugin' import { BSON, Collection } from 'mongodb' -import { IProblemSettings } from '../schemas/problem.js' +import { IProblemSettings, SProblemSolutionRuleResult } from '../schemas/problem.js' import { capabilityMask } from '../utils/capability.js' -import { IPrincipalControlable, IWithAccessLevel, IWithAttachment, IWithContent } from './common.js' +import { + IPrincipalControlable, + IWithAccessLevel, + IWithAttachment, + IWithContent, + RulesFromSchemas +} from './common.js' +import { ISolution } from './solution.js' export const PROBLEM_CAPS = { CAP_ACCESS: capabilityMask(0), // Can access(view) this problem @@ -32,6 +39,16 @@ export interface IProblemData { createdAt: number } +export interface IProblemSolutionRuleCtx { + problem: IProblem + currentResult: IProblemStatus | null + solution: ISolution +} + +export const problemRuleSchemas = { + solution: SProblemSolutionRuleResult +} + export interface IProblem extends IPrincipalControlable, IWithAttachment, @@ -50,6 +67,13 @@ export interface IProblem settings: IProblemSettings createdAt: number + + rules?: RulesFromSchemas< + typeof problemRuleSchemas, + { + solution: IProblemSolutionRuleCtx + } + > } declare module './index.js' { diff --git a/apps/server/src/routes/contest/scoped.ts b/apps/server/src/routes/contest/scoped.ts index aeed62cf..08b32654 100644 --- a/apps/server/src/routes/contest/scoped.ts +++ b/apps/server/src/routes/contest/scoped.ts @@ -1,9 +1,14 @@ import { BSON } from 'mongodb' -import { CONTEST_CAPS, ORG_CAPS, evalTagRules, getCurrentContestStage } from '../../db/index.js' -import { SContestStage } from '../../schemas/contest.js' -import { T } from '../../schemas/index.js' -import { CAP_ALL, ensureCapability, hasCapability } from '../../utils/index.js' +import { + CONTEST_CAPS, + IContestParticipantRuleCtx, + ORG_CAPS, + evalTagRules, + getCurrentContestStage +} from '../../db/index.js' +import { SContestParticipantRuleResult, SContestStage, T } from '../../schemas/index.js' +import { CAP_ALL, createEvaluator, ensureCapability, hasCapability } from '../../utils/index.js' import { manageContent } from '../common/content.js' import { defineRoutes, loadCapability, loadUUID, paramSchemaMerger } from '../common/index.js' @@ -17,6 +22,9 @@ import { contestSolutionRoutes } from './solution/index.js' export const contestScopedRoutes = defineRoutes(async (s) => { const { contests, contestParticipants, users } = s.db + const participantRuleEvaluator = createEvaluator( + SContestParticipantRuleResult + ) s.addHook( 'onRoute', @@ -114,20 +122,39 @@ export const contestScopedRoutes = defineRoutes(async (s) => { const user = await users.findOne({ _id: req.user.userId }) if (!user) return rep.notFound() + let tags = await evalTagRules(ctx._contestStage, user) + if (ctx._contest.rules?.participant) { + const { allowRegister, tags: _tags } = participantRuleEvaluator( + { + contest: ctx._contest, + currentStage: ctx._contestStage, + user + }, + ctx._contest.rules?.participant, + {} + ) + if (typeof allowRegister === 'string') { + return rep.forbidden(allowRegister) + } + if (allowRegister === false) { + return rep.forbidden('Cannot register for contest') + } + tags ??= _tags + } await contestParticipants.insertOne( { _id: new BSON.UUID(), userId: req.user.userId, contestId: ctx._contestId, results: {}, - tags: await evalTagRules(ctx._contestStage, user), + tags, createdAt: req._now, updatedAt: req._now }, { ignoreUndefined: true } ) - // TODO: add to the corresponding contest + // TODO: This operation is not atomic along with registration await contests.updateOne({ _id: ctx._contestId }, { $inc: { participantCount: 1 } }) return {} diff --git a/apps/server/src/routes/contest/solution/index.ts b/apps/server/src/routes/contest/solution/index.ts index e1d8e391..6f8fd89f 100644 --- a/apps/server/src/routes/contest/solution/index.ts +++ b/apps/server/src/routes/contest/solution/index.ts @@ -219,7 +219,7 @@ const solutionScopedRoutes = defineRoutes(async (s) => { const { solutionShowOtherData } = ctx._contestStage.settings let showData = !!solutionShowOtherData if (ctx._contest.rules?.solution) { - ;({ showData } = solutionRuleEvaluator( + const { showData: _showData } = solutionRuleEvaluator( { contest: ctx._contest, currentStage: ctx._contestStage, @@ -227,10 +227,12 @@ const solutionScopedRoutes = defineRoutes(async (s) => { currentResult: ctx._contestParticipant?.results[solution.problemId.toString()] ?? null, solution }, - ctx._contest.rules?.solution, - {}, - { showData } - )) + ctx._contest.rules?.solution + ) + if (typeof _showData === 'string') { + throw s.httpErrors.forbidden(_showData) + } + showData = _showData ?? showData } if (!checkUser(req, solution.userId, showData)) throw s.httpErrors.forbidden() return [ctx._contest.orgId, solutionDataKey(solution._id)] diff --git a/apps/server/src/routes/problem/admin.ts b/apps/server/src/routes/problem/admin.ts index 19b93ff0..8eb31d0a 100644 --- a/apps/server/src/routes/problem/admin.ts +++ b/apps/server/src/routes/problem/admin.ts @@ -1,9 +1,9 @@ -import { PROBLEM_CAPS, SolutionState } from '../../db/index.js' +import { PROBLEM_CAPS, SolutionState, problemRuleSchemas } from '../../db/index.js' import { SProblemSettings } from '../../index.js' import { T } from '../../schemas/index.js' import { ensureCapability } from '../../utils/capability.js' import { manageACL, manageAccessLevel } from '../common/access.js' -import { defineRoutes } from '../common/index.js' +import { defineRoutes, manageRules } from '../common/index.js' import { manageSettings } from '../common/settings.js' import { kProblemContext } from './inject.js' @@ -21,23 +21,30 @@ export const problemAdminRoutes = defineRoutes(async (s) => { s.register(manageACL, { collection: problems, - resolve: async (req) => req.inject(kProblemContext)._problem._id, + resolve: async (req) => req.inject(kProblemContext)._problemId, defaultCapability: PROBLEM_CAPS.CAP_ACCESS, prefix: '/access' }) s.register(manageAccessLevel, { collection: problems, - resolve: async (req) => req.inject(kProblemContext)._problem._id, + resolve: async (req) => req.inject(kProblemContext)._problemId, prefix: '/accessLevel' }) s.register(manageSettings, { collection: problems, - resolve: async (req) => req.inject(kProblemContext)._problem._id, + resolve: async (req) => req.inject(kProblemContext)._problemId, schema: SProblemSettings, prefix: '/settings' }) + s.register(manageRules, { + collection: problems, + resolve: async (req) => req.inject(kProblemContext)._problemId, + schemas: problemRuleSchemas, + prefix: '/rule' + }) + s.post( '/submit-all', { @@ -133,7 +140,7 @@ export const problemAdminRoutes = defineRoutes(async (s) => { }, async (req) => { // TODO: handle dependencies - await problems.deleteOne({ _id: req.inject(kProblemContext)._problem._id }) + await problems.deleteOne({ _id: req.inject(kProblemContext)._problemId }) return {} } ) diff --git a/apps/server/src/routes/problem/solution.ts b/apps/server/src/routes/problem/solution.ts index ee4929db..39b6b2f4 100644 --- a/apps/server/src/routes/problem/solution.ts +++ b/apps/server/src/routes/problem/solution.ts @@ -1,16 +1,17 @@ import { BSON } from 'mongodb' -import { ISolution, PROBLEM_CAPS, SolutionState } from '../../db/index.js' +import { IProblemSolutionRuleCtx, ISolution, PROBLEM_CAPS, SolutionState } from '../../db/index.js' import { solutionDataKey, solutionDetailsKey } from '../../index.js' -import { T } from '../../schemas/index.js' -import { findPaginated, hasCapability } from '../../utils/index.js' +import { SProblemSolutionRuleResult, T } from '../../schemas/index.js' +import { createEvaluator, findPaginated, hasCapability } from '../../utils/index.js' import { getFileUrl } from '../common/files.js' import { defineRoutes, generateRangeQuery, loadUUID, paramSchemaMerger } from '../common/index.js' import { kProblemContext } from './inject.js' const solutionScopedRoutes = defineRoutes(async (s) => { - const { solutions } = s.db + const { solutions, problemStatuses } = s.db + const solutionRuleEvaluator = createEvaluator(SProblemSolutionRuleResult) s.addHook( 'onRoute', @@ -183,8 +184,6 @@ const solutionScopedRoutes = defineRoutes(async (s) => { resolve: async (type, query, req) => { const ctx = req.inject(kProblemContext) - const { solutionShowOtherData } = ctx._problem.settings - const admin = hasCapability(ctx._problemCapability, PROBLEM_CAPS.CAP_ADMIN) const solutionId = loadUUID(req.params, 'solutionId', s.httpErrors.badRequest()) const solution = await solutions.findOne({ _id: solutionId, @@ -192,7 +191,29 @@ const solutionScopedRoutes = defineRoutes(async (s) => { problemId: ctx._problemId }) if (!solution) throw s.httpErrors.notFound() - if (!solutionShowOtherData && !admin && !solution.userId.equals(req.user.userId)) { + const { solutionShowOtherData } = ctx._problem.settings + let showData = !!solutionShowOtherData + if (ctx._problem.rules?.solution) { + const currentResult = await problemStatuses.findOne({ + userId: req.user.userId, + problemId: ctx._problemId + }) + const { showData: _showData } = solutionRuleEvaluator( + { + problem: ctx._problem, + currentResult, + solution + }, + ctx._problem.rules.solution + ) + if (typeof _showData === 'string') { + throw s.httpErrors.forbidden(_showData) + } + showData = _showData ?? showData + } + const admin = hasCapability(ctx._problemCapability, PROBLEM_CAPS.CAP_ADMIN) + + if (!showData && !admin && !solution.userId.equals(req.user.userId)) { throw s.httpErrors.forbidden() } return [ctx._problem.orgId, solutionDataKey(solution._id)] diff --git a/apps/server/src/schemas/common.ts b/apps/server/src/schemas/common.ts index 9792ff38..18444a9d 100644 --- a/apps/server/src/schemas/common.ts +++ b/apps/server/src/schemas/common.ts @@ -1,4 +1,10 @@ -import { JavaScriptTypeBuilder, TSchema, ObjectOptions, TProperties } from '@sinclair/typebox' +import { + JavaScriptTypeBuilder, + TSchema, + ObjectOptions, + TProperties, + StringOptions +} from '@sinclair/typebox' import { BSON } from 'mongodb' import './formats.js' @@ -72,8 +78,8 @@ export class ServerTypeBuilder extends JavaScriptTypeBuilder { } RuleSet(result: T) { - const projector = this.Mapped(this.KeyOf(result), (K) => - this.Union([this.Index(result, K), this.String()]) + const projector = this.Partial( + this.Mapped(this.KeyOf(result), (K) => this.Union([this.Index(result, K), this.String()])) ) const rule = this.Object({ @@ -86,6 +92,10 @@ export class ServerTypeBuilder extends JavaScriptTypeBuilder { defaults: this.Optional(projector) }) } + + BooleanOrString(options?: StringOptions) { + return this.Union([this.Boolean(), this.String(options)]) + } } export const T = new ServerTypeBuilder() diff --git a/apps/server/src/schemas/contest.ts b/apps/server/src/schemas/contest.ts index fa26050f..99f375c2 100644 --- a/apps/server/src/schemas/contest.ts +++ b/apps/server/src/schemas/contest.ts @@ -61,7 +61,15 @@ export const SContestRanklistSettings = T.StrictObject({ export interface IContestRanklistSettings extends Static {} export const SContestSolutionRuleResult = T.StrictObject({ - showData: T.Boolean() + showData: T.BooleanOrString() }) export interface IContestSolutionRuleResult extends Static {} + +export const SContestParticipantRuleResult = T.StrictObject({ + allowRegister: T.BooleanOrString(), + tags: T.Array(T.String()) +}) + +export interface IContestParticipantRuleResult + extends Static {} diff --git a/apps/server/src/schemas/problem.ts b/apps/server/src/schemas/problem.ts index b5e109d3..31d9ebfd 100644 --- a/apps/server/src/schemas/problem.ts +++ b/apps/server/src/schemas/problem.ts @@ -16,3 +16,9 @@ export const SProblemSettings = T.Partial( ) export interface IProblemSettings extends Static {} + +export const SProblemSolutionRuleResult = T.StrictObject({ + showData: T.BooleanOrString() +}) + +export interface IProblemSolutionRuleResult extends Static {} diff --git a/apps/server/src/utils/rule.ts b/apps/server/src/utils/rule.ts index 9272f028..0cde3cb2 100644 --- a/apps/server/src/utils/rule.ts +++ b/apps/server/src/utils/rule.ts @@ -3,8 +3,10 @@ import { httpErrors } from '@fastify/sensible' import { Static, TSchema } from '@sinclair/typebox' import { TypeCompiler } from '@sinclair/typebox/compiler' +import { T } from '../schemas/common.js' + export function createEvaluator(schema: T) { - const checker = TypeCompiler.Compile(schema) + const checker = TypeCompiler.Compile(T.Partial(schema)) type Result = Static return ( context: Context, diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 6dda9752..d61c0d41 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -26,6 +26,7 @@ export default defineConfig({ items: [ { text: '使用指南', link: '/user-guide' }, { text: '管理指南', link: '/admin-guide' }, + { text: '规则系统', link: '/rule' }, { text: '开发指南', link: '/dev-guide' } ] } diff --git a/docs/rule.md b/docs/rule.md new file mode 100644 index 00000000..a848c1bd --- /dev/null +++ b/docs/rule.md @@ -0,0 +1,157 @@ +--- +outline: deep +--- + +# 规则系统 + +## 概述 + +AOI v1.1.x 起引入了规则系统,用于增强的权限控制。 + +利用规则系统,您可以实现包括但不限于如下的功能: + +- 仅当用户达到特定分数后,才可以查看其他人的提交 +- 在权限控制的基础上,限制满足特定条件的用户参与比赛 +- …… + +规则是声明性的判断流水线。对于一处**规则设置**,AOI系统将提供对应的**规则上下文**,上下文中的数据将可以被**规则匹配器(Matcher)**和**规则投影器(Projector)**使用。 + +规则以JSON格式存储。其(简化的)类型描述如下: + +```ts +export type Matcher = { + [key in string]: Condition +} + +export type Projector = { + [key in string]: string | any +} + +export interface IRule { + match: Matcher | Matcher[] + returns: Projector +} + +export interface IRuleSet { + rules: IRule[] + defaults?: Projector +} +``` + +完整的类型描述请参见 `libs/rule` 包中的源码。您不必理解上述类型定义。 + +### 规则集(RuleSet) + +每一处**规则设置**都可以配置一个**规则集**。一个规则集可以视作一个流水线。对于给定的上下文,AOI系统将按照规则集中定义的 `rules` 逐个匹配,若均匹配失败,则使用 `defaults` 中定义的投影器。 + +### 规则(Rule) + +每一个规则都包含两个部分:`match` 和 `returns`。`match` 用于定义规则匹配器,`returns` 用于定义规则投影器。 + +若匹配器成功匹配,则将返回对应的投影器。 + +### 规则匹配器(Matcher) + +规则匹配器是一个对象,其中: + +- 键值为希望匹配的规则上下文中数据的路径(对于嵌套对象,使用 `.` 分隔) +- 值为一个条件对象 + +条件对象也是一个对象,其中 + +- 键值为判断类型 +- 值为判断条件 + +支持的判断类型有: + +- `$eq` `$ne` `$gt` `$gte` `$lt` `$lte`:将上下文的对应值与给定值进行比较。比较是类型严格的。 +- `$in` `$nin`:测试上下文的对应值是否在给定的数组中。 +- `$startsWith` `$endsWith`:测试上下文的对应值是否满足给定的字符串条件。若上下文对应值不是字符串,将先转化为字符串。 + +### 规则投影器(Projector) + +规则投影器是一个特殊的对象,其中满足特定条件的值会进行替换。具体如下: + +- 若值不为字符串,不进行替换; +- 若值为字符串,但不以 `$` 开头,不进行替换; +- 若值为字符串,且以 `$$` 开头,将移除一个 `$` 并返回; +- 否则,进行替换。 + +替换时,若键满足 `$.some.path` 形式,将使用匹配上下文中对应路径的值。 + +## 支持的规则设置 + +### 比赛 + +#### 提交规则(solution) + +上下文: + +```ts +export interface IContestSolutionRuleCtx { + contest: IContest + currentStage: IContestStage + participant: IContestParticipant | null + currentResult: IContestParticipantResult | null + solution: ISolution +} +``` + +返回类型: + +```ts +interface Result { + // 是否显示提交数据 + // true - 显示 false或字符串 - 不显示并返回错误消息 + showData?: boolean | string +} +``` + +#### 参赛规则(participant) + +上下文: + +```ts +export interface IContestParticipantRuleCtx { + contest: IContest + currentStage: IContestStage + user: IUser +} +``` + +返回类型: + +```ts +interface Result { + // 是否允许参赛 + // true - 允许 false或字符串 - 不允许并返回错误消息 + allowRegister?: boolean | string + + // 选手标签 注意:若指定,将覆盖标签规则(Tag Rules) + tags?: string[] +} +``` + +### 题目 + +#### 提交规则(solution) + +上下文: + +```ts +export interface IProblemSolutionRuleCtx { + problem: IProblem + currentResult: IProblemStatus | null + solution: ISolution +} +``` + +返回类型: + +```ts +interface Result { + // 是否显示提交数据 + // true - 显示 false或字符串 - 不显示并返回错误消息 + showData?: boolean | string +} +```