You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
612 lines
18 KiB
612 lines
18 KiB
import * as TypeORM from "typeorm"; |
|
import Model from "./common"; |
|
|
|
declare var syzoj, ErrorMessage: any; |
|
|
|
import User from "./user"; |
|
import File from "./file"; |
|
import JudgeState from "./judge_state"; |
|
import Contest from "./contest"; |
|
import ProblemTag from "./problem_tag"; |
|
import ProblemTagMap from "./problem_tag_map"; |
|
import SubmissionStatistics, { StatisticsType } from "./submission_statistics"; |
|
|
|
import * as fs from "fs-extra"; |
|
import * as path from "path"; |
|
import * as util from "util"; |
|
import * as LRUCache from "lru-cache"; |
|
import * as DeepCopy from "deepcopy"; |
|
|
|
const problemTagCache = new LRUCache<number, number[]>({ |
|
max: syzoj.config.db.cache_size |
|
}); |
|
|
|
enum ProblemType { |
|
Traditional = "traditional", |
|
SubmitAnswer = "submit-answer", |
|
Interaction = "interaction" |
|
} |
|
|
|
const statisticsTypes = { |
|
fastest: ['total_time', 'ASC'], |
|
slowest: ['total_time', 'DESC'], |
|
shortest: ['code_length', 'ASC'], |
|
longest: ['code_length', 'DESC'], |
|
min: ['max_memory', 'ASC'], |
|
max: ['max_memory', 'DESC'], |
|
earliest: ['submit_time', 'ASC'] |
|
}; |
|
|
|
const statisticsCodeOnly = ["fastest", "slowest", "min", "max"]; |
|
|
|
@TypeORM.Entity() |
|
export default class Problem extends Model { |
|
static cache = true; |
|
|
|
@TypeORM.PrimaryGeneratedColumn() |
|
id: number; |
|
|
|
@TypeORM.Column({ nullable: true, type: "varchar", length: 80 }) |
|
title: string; |
|
|
|
@TypeORM.Index() |
|
@TypeORM.Column({ nullable: true, type: "integer" }) |
|
user_id: number; |
|
|
|
@TypeORM.Column({ nullable: true, type: "integer" }) |
|
publicizer_id: number; |
|
|
|
@TypeORM.Column({ nullable: true, type: "boolean" }) |
|
is_anonymous: boolean; |
|
|
|
@TypeORM.Column({ nullable: true, type: "text" }) |
|
description: string; |
|
|
|
@TypeORM.Column({ nullable: true, type: "text" }) |
|
input_format: string; |
|
|
|
@TypeORM.Column({ nullable: true, type: "text" }) |
|
output_format: string; |
|
|
|
@TypeORM.Column({ nullable: true, type: "text" }) |
|
example: string; |
|
|
|
@TypeORM.Column({ nullable: true, type: "text" }) |
|
limit_and_hint: string; |
|
|
|
@TypeORM.Column({ nullable: true, type: "integer" }) |
|
time_limit: number; |
|
|
|
@TypeORM.Column({ nullable: true, type: "integer" }) |
|
memory_limit: number; |
|
|
|
@TypeORM.Column({ nullable: true, type: "integer" }) |
|
additional_file_id: number; |
|
|
|
@TypeORM.Column({ nullable: true, type: "integer" }) |
|
ac_num: number; |
|
|
|
@TypeORM.Column({ nullable: true, type: "integer" }) |
|
submit_num: number; |
|
|
|
@TypeORM.Index() |
|
@TypeORM.Column({ nullable: true, type: "boolean" }) |
|
is_public: boolean; |
|
|
|
@TypeORM.Column({ nullable: true, type: "boolean" }) |
|
file_io: boolean; |
|
|
|
@TypeORM.Column({ nullable: true, type: "text" }) |
|
file_io_input_name: string; |
|
|
|
@TypeORM.Column({ nullable: true, type: "text" }) |
|
file_io_output_name: string; |
|
|
|
@TypeORM.Index() |
|
@TypeORM.Column({ nullable: true, type: "datetime" }) |
|
publicize_time: Date; |
|
|
|
@TypeORM.Column({ nullable: true, |
|
type: "enum", |
|
enum: ProblemType, |
|
default: ProblemType.Traditional |
|
}) |
|
type: ProblemType; |
|
|
|
user?: User; |
|
publicizer?: User; |
|
additional_file?: File; |
|
|
|
async loadRelationships() { |
|
this.user = await User.findById(this.user_id); |
|
this.publicizer = await User.findById(this.publicizer_id); |
|
this.additional_file = await File.findById(this.additional_file_id); |
|
} |
|
|
|
async isAllowedEditBy(user) { |
|
if (!user) return false; |
|
if (await user.hasPrivilege('manage_problem')) return true; |
|
return this.user_id === user.id; |
|
} |
|
|
|
async isAllowedUseBy(user) { |
|
if (this.is_public) return true; |
|
if (!user) return false; |
|
if (await user.hasPrivilege('manage_problem')) return true; |
|
return this.user_id === user.id; |
|
} |
|
|
|
async isAllowedManageBy(user) { |
|
if (!user) return false; |
|
if (await user.hasPrivilege('manage_problem')) return true; |
|
return user.is_admin; |
|
} |
|
|
|
getTestdataPath() { |
|
return syzoj.utils.resolvePath(syzoj.config.upload_dir, 'testdata', this.id.toString()); |
|
} |
|
|
|
getTestdataArchivePath() { |
|
return syzoj.utils.resolvePath(syzoj.config.upload_dir, 'testdata-archive', this.id.toString() + '.zip'); |
|
} |
|
|
|
async updateTestdata(path, noLimit) { |
|
await syzoj.utils.lock(['Problem::Testdata', this.id], async () => { |
|
let p7zip = new (require('node-7z')); |
|
|
|
let unzipSize = 0, unzipCount; |
|
await p7zip.list(path).progress(files => { |
|
unzipCount = files.length; |
|
for (let file of files) unzipSize += file.size; |
|
}); |
|
if (!noLimit && unzipCount > syzoj.config.limit.testdata_filecount) throw new ErrorMessage('数据包中的文件太多。'); |
|
if (!noLimit && unzipSize > syzoj.config.limit.testdata) throw new ErrorMessage('数据包太大。'); |
|
|
|
let dir = this.getTestdataPath(); |
|
await fs.remove(dir); |
|
await fs.ensureDir(dir); |
|
|
|
let execFileAsync = util.promisify(require('child_process').execFile); |
|
await execFileAsync(__dirname + '/../bin/unzip', ['-j', '-o', '-d', dir, path]); |
|
await fs.move(path, this.getTestdataArchivePath(), { overwrite: true }); |
|
}); |
|
} |
|
|
|
async uploadTestdataSingleFile(filename, filepath, size, noLimit) { |
|
await syzoj.utils.lock(['Promise::Testdata', this.id], async () => { |
|
let dir = this.getTestdataPath(); |
|
await fs.ensureDir(dir); |
|
|
|
let oldSize = 0, list = await this.listTestdata(), replace = false, oldCount = 0; |
|
if (list) { |
|
oldCount = list.files.length; |
|
for (let file of list.files) { |
|
if (file.filename !== filename) oldSize += file.size; |
|
else replace = true; |
|
} |
|
} |
|
|
|
if (!noLimit && oldSize + size > syzoj.config.limit.testdata) throw new ErrorMessage('数据包太大。'); |
|
if (!noLimit && oldCount + (!replace as any as number) > syzoj.config.limit.testdata_filecount) throw new ErrorMessage('数据包中的文件太多。'); |
|
|
|
await fs.move(filepath, path.join(dir, filename), { overwrite: true }); |
|
|
|
let execFileAsync = util.promisify(require('child_process').execFile); |
|
try { await execFileAsync('dos2unix', [path.join(dir, filename)]); } catch (e) {} |
|
|
|
await fs.remove(this.getTestdataArchivePath()); |
|
}); |
|
} |
|
|
|
async deleteTestdataSingleFile(filename) { |
|
await syzoj.utils.lock(['Promise::Testdata', this.id], async () => { |
|
await fs.remove(path.join(this.getTestdataPath(), filename)); |
|
await fs.remove(this.getTestdataArchivePath()); |
|
}); |
|
} |
|
|
|
async makeTestdataZip() { |
|
await syzoj.utils.lock(['Promise::Testdata', this.id], async () => { |
|
let dir = this.getTestdataPath(); |
|
if (!await syzoj.utils.isDir(dir)) throw new ErrorMessage('无测试数据。'); |
|
|
|
let p7zip = new (require('node-7z')); |
|
|
|
let list = await this.listTestdata(), pathlist = list.files.map(file => path.join(dir, file.filename)); |
|
if (!pathlist.length) throw new ErrorMessage('无测试数据。'); |
|
await fs.ensureDir(path.resolve(this.getTestdataArchivePath(), '..')); |
|
await p7zip.add(this.getTestdataArchivePath(), pathlist); |
|
}); |
|
} |
|
|
|
async hasSpecialJudge() { |
|
try { |
|
let dir = this.getTestdataPath(); |
|
let list = await fs.readdir(dir); |
|
return list.includes('spj.js') || list.find(x => x.startsWith('spj_')) !== undefined; |
|
} catch (e) { |
|
return false; |
|
} |
|
} |
|
|
|
async listTestdata() { |
|
try { |
|
let dir = this.getTestdataPath(); |
|
let filenameList = await fs.readdir(dir); |
|
let list = await Promise.all(filenameList.map(async x => { |
|
let stat = await fs.stat(path.join(dir, x)); |
|
if (!stat.isFile()) return undefined; |
|
return { |
|
filename: x, |
|
size: stat.size |
|
}; |
|
})); |
|
|
|
list = list.filter(x => x); |
|
|
|
let res = { |
|
files: list, |
|
zip: null |
|
}; |
|
|
|
try { |
|
let stat = await fs.stat(this.getTestdataArchivePath()); |
|
if (stat.isFile()) { |
|
res.zip = { |
|
size: stat.size |
|
}; |
|
} |
|
} catch (e) { |
|
if (list) { |
|
res.zip = { |
|
size: null |
|
}; |
|
} |
|
} |
|
|
|
return res; |
|
} catch (e) { |
|
return null; |
|
} |
|
} |
|
|
|
async updateFile(path, type, noLimit) { |
|
let file = await File.upload(path, type, noLimit); |
|
|
|
if (type === 'additional_file') { |
|
this.additional_file_id = file.id; |
|
} |
|
|
|
await this.save(); |
|
} |
|
|
|
async validate() { |
|
if (this.time_limit <= 0) return 'Invalid time limit'; |
|
if (this.time_limit > syzoj.config.limit.time_limit) return 'Time limit too large'; |
|
if (this.memory_limit <= 0) return 'Invalid memory limit'; |
|
if (this.memory_limit > syzoj.config.limit.memory_limit) return 'Memory limit too large'; |
|
if (!['traditional', 'submit-answer', 'interaction'].includes(this.type)) return 'Invalid problem type'; |
|
|
|
if (this.type === 'traditional') { |
|
let filenameRE = /^[\w \-\+\.]*$/; |
|
if (this.file_io_input_name && !filenameRE.test(this.file_io_input_name)) return 'Invalid input file name'; |
|
if (this.file_io_output_name && !filenameRE.test(this.file_io_output_name)) return 'Invalid output file name'; |
|
|
|
if (this.file_io) { |
|
if (!this.file_io_input_name) return 'No input file name'; |
|
if (!this.file_io_output_name) return 'No output file name'; |
|
} |
|
} |
|
|
|
return null; |
|
} |
|
|
|
async getJudgeState(user, acFirst) { |
|
if (!user) return null; |
|
|
|
let where: any = { |
|
user_id: user.id, |
|
problem_id: this.id |
|
}; |
|
|
|
if (acFirst) { |
|
where.status = 'Accepted'; |
|
|
|
let state = await JudgeState.findOne({ |
|
where: where, |
|
order: { |
|
submit_time: 'DESC' |
|
} |
|
}); |
|
|
|
if (state) return state; |
|
} |
|
|
|
if (where.status) delete where.status; |
|
|
|
return await JudgeState.findOne({ |
|
where: where, |
|
order: { |
|
submit_time: 'DESC' |
|
} |
|
}); |
|
} |
|
|
|
async resetSubmissionCount() { |
|
await syzoj.utils.lock(['Problem::resetSubmissionCount', this.id], async () => { |
|
this.submit_num = await JudgeState.count({ problem_id: this.id, type: TypeORM.Not(1) }); |
|
this.ac_num = await JudgeState.count({ score: 100, problem_id: this.id, type: TypeORM.Not(1) }); |
|
await this.save(); |
|
}); |
|
} |
|
|
|
async updateStatistics(user_id) { |
|
await Promise.all(Object.keys(statisticsTypes).map(async type => { |
|
if (this.type === ProblemType.SubmitAnswer && statisticsCodeOnly.includes(type)) return; |
|
|
|
await syzoj.utils.lock(['Problem::UpdateStatistics', this.id, type], async () => { |
|
const [column, order] = statisticsTypes[type]; |
|
const result = await JudgeState.createQueryBuilder() |
|
.select([column, "id"]) |
|
.where("user_id = :user_id", { user_id }) |
|
.andWhere("status = :status", { status: "Accepted" }) |
|
.andWhere("problem_id = :problem_id", { problem_id: this.id }) |
|
.orderBy({ [column]: order }) |
|
.take(1) |
|
.getRawMany(); |
|
const resultRow = result[0]; |
|
|
|
let toDelete = false; |
|
if (!resultRow || resultRow[column] == null) { |
|
toDelete = true; |
|
} |
|
|
|
const baseColumns = { |
|
user_id, |
|
problem_id: this.id, |
|
type: type as StatisticsType |
|
}; |
|
|
|
let record = await SubmissionStatistics.findOne(baseColumns); |
|
|
|
if (toDelete) { |
|
if (record) { |
|
await record.destroy(); |
|
} |
|
|
|
return; |
|
} |
|
|
|
if (!record) { |
|
record = SubmissionStatistics.create(baseColumns); |
|
} |
|
|
|
record.key = resultRow[column]; |
|
record.submission_id = resultRow["id"]; |
|
|
|
await record.save(); |
|
}); |
|
})); |
|
} |
|
|
|
async countStatistics(type) { |
|
if (!statisticsTypes[type] || this.type === ProblemType.SubmitAnswer && statisticsCodeOnly.includes(type)) { |
|
return null; |
|
} |
|
|
|
return await SubmissionStatistics.count({ |
|
problem_id: this.id, |
|
type: type |
|
}); |
|
} |
|
|
|
async getStatistics(type, paginate) { |
|
if (!statisticsTypes[type] || this.type === ProblemType.SubmitAnswer && statisticsCodeOnly.includes(type)) { |
|
return null; |
|
} |
|
|
|
const statistics = { |
|
type: type, |
|
judge_state: null, |
|
scoreDistribution: null, |
|
prefixSum: null, |
|
suffixSum: null |
|
}; |
|
|
|
const order = statisticsTypes[type][1]; |
|
const ids = (await SubmissionStatistics.queryPage(paginate, { |
|
problem_id: this.id, |
|
type: type |
|
}, { |
|
'`key`': order |
|
})).map(x => x.submission_id); |
|
|
|
statistics.judge_state = ids.length ? await JudgeState.createQueryBuilder() |
|
.whereInIds(ids) |
|
.orderBy(`FIELD(id,${ids.join(',')})`) |
|
.getMany() |
|
: []; |
|
|
|
const a = await JudgeState.createQueryBuilder() |
|
.select('score') |
|
.addSelect('COUNT(*)', 'count') |
|
.where('problem_id = :problem_id', { problem_id: this.id }) |
|
.andWhere('type = 0') |
|
.andWhere('pending = false') |
|
.groupBy('score') |
|
.getRawMany(); |
|
|
|
let scoreCount = []; |
|
for (let score of a) { |
|
score.score = Math.min(Math.round(score.score), 100); |
|
scoreCount[score.score] = score.count; |
|
} |
|
if (scoreCount[0] === undefined) scoreCount[0] = 0; |
|
if (scoreCount[100] === undefined) scoreCount[100] = 0; |
|
|
|
if (a[null as any]) { |
|
a[0] += a[null as any]; |
|
delete a[null as any]; |
|
} |
|
|
|
statistics.scoreDistribution = []; |
|
for (let i = 0; i < scoreCount.length; i++) { |
|
if (scoreCount[i] !== undefined) statistics.scoreDistribution.push({ score: i, count: parseInt(scoreCount[i]) }); |
|
} |
|
|
|
statistics.prefixSum = DeepCopy(statistics.scoreDistribution); |
|
statistics.suffixSum = DeepCopy(statistics.scoreDistribution); |
|
|
|
for (let i = 1; i < statistics.prefixSum.length; i++) { |
|
statistics.prefixSum[i].count += statistics.prefixSum[i - 1].count; |
|
} |
|
|
|
for (let i = statistics.prefixSum.length - 1; i >= 1; i--) { |
|
statistics.suffixSum[i - 1].count += statistics.suffixSum[i].count; |
|
} |
|
|
|
return statistics; |
|
} |
|
|
|
async getTags() { |
|
let tagIDs; |
|
if (problemTagCache.has(this.id)) { |
|
tagIDs = problemTagCache.get(this.id); |
|
} else { |
|
let maps = await ProblemTagMap.find({ |
|
where: { |
|
problem_id: this.id |
|
} |
|
}); |
|
|
|
tagIDs = maps.map(x => x.tag_id); |
|
problemTagCache.set(this.id, tagIDs); |
|
} |
|
|
|
let res = await (tagIDs as any).mapAsync(async tagID => { |
|
return ProblemTag.findById(tagID); |
|
}); |
|
|
|
res.sort((a, b) => { |
|
return a.color > b.color ? 1 : -1; |
|
}); |
|
|
|
return res; |
|
} |
|
|
|
async setTags(newTagIDs) { |
|
let oldTagIDs = (await this.getTags()).map(x => x.id); |
|
|
|
let delTagIDs = oldTagIDs.filter(x => !newTagIDs.includes(x)); |
|
let addTagIDs = newTagIDs.filter(x => !oldTagIDs.includes(x)); |
|
|
|
for (let tagID of delTagIDs) { |
|
let map = await ProblemTagMap.findOne({ |
|
where: { |
|
problem_id: this.id, |
|
tag_id: tagID |
|
} |
|
}); |
|
|
|
await map.destroy(); |
|
} |
|
|
|
for (let tagID of addTagIDs) { |
|
let map = await ProblemTagMap.create({ |
|
problem_id: this.id, |
|
tag_id: tagID |
|
}); |
|
|
|
await map.save(); |
|
} |
|
|
|
problemTagCache.set(this.id, newTagIDs); |
|
} |
|
|
|
async changeID(id) { |
|
const entityManager = TypeORM.getManager(); |
|
|
|
id = parseInt(id); |
|
await entityManager.query('UPDATE `problem` SET `id` = ' + id + ' WHERE `id` = ' + this.id); |
|
await entityManager.query('UPDATE `judge_state` SET `problem_id` = ' + id + ' WHERE `problem_id` = ' + this.id); |
|
await entityManager.query('UPDATE `problem_tag_map` SET `problem_id` = ' + id + ' WHERE `problem_id` = ' + this.id); |
|
await entityManager.query('UPDATE `article` SET `problem_id` = ' + id + ' WHERE `problem_id` = ' + this.id); |
|
await entityManager.query('UPDATE `submission_statistics` SET `problem_id` = ' + id + ' WHERE `problem_id` = ' + this.id); |
|
|
|
let contests = await Contest.find(); |
|
for (let contest of contests) { |
|
let problemIDs = await contest.getProblems(); |
|
|
|
let flag = false; |
|
for (let i in problemIDs) { |
|
if (problemIDs[i] === this.id) { |
|
problemIDs[i] = id; |
|
flag = true; |
|
} |
|
} |
|
|
|
if (flag) { |
|
await contest.setProblemsNoCheck(problemIDs); |
|
await contest.save(); |
|
} |
|
} |
|
|
|
let oldTestdataDir = this.getTestdataPath(), oldTestdataZip = this.getTestdataArchivePath(); |
|
|
|
const oldID = this.id; |
|
this.id = id; |
|
|
|
// Move testdata |
|
let newTestdataDir = this.getTestdataPath(), newTestdataZip = this.getTestdataArchivePath(); |
|
if (await syzoj.utils.isDir(oldTestdataDir)) { |
|
await fs.move(oldTestdataDir, newTestdataDir); |
|
} |
|
|
|
if (await syzoj.utils.isFile(oldTestdataZip)) { |
|
await fs.move(oldTestdataZip, newTestdataZip); |
|
} |
|
|
|
await this.save(); |
|
|
|
await Problem.deleteFromCache(oldID); |
|
await problemTagCache.del(oldID); |
|
} |
|
|
|
async delete() { |
|
const entityManager = TypeORM.getManager(); |
|
|
|
let oldTestdataDir = this.getTestdataPath(), oldTestdataZip = this.getTestdataPath(); |
|
await fs.remove(oldTestdataDir); |
|
await fs.remove(oldTestdataZip); |
|
|
|
let submissions = await JudgeState.find({ |
|
where: { |
|
problem_id: this.id |
|
} |
|
}), submitCnt = {}, acUsers = new Set(); |
|
for (let sm of submissions) { |
|
if (sm.status === 'Accepted') acUsers.add(sm.user_id); |
|
if (!submitCnt[sm.user_id]) { |
|
submitCnt[sm.user_id] = 1; |
|
} else { |
|
submitCnt[sm.user_id]++; |
|
} |
|
} |
|
|
|
for (let u in submitCnt) { |
|
let user = await User.findById(parseInt(u)); |
|
user.submit_num -= submitCnt[u]; |
|
if (acUsers.has(parseInt(u))) user.ac_num--; |
|
await user.save(); |
|
} |
|
|
|
problemTagCache.del(this.id); |
|
|
|
await entityManager.query('DELETE FROM `judge_state` WHERE `problem_id` = ' + this.id); |
|
await entityManager.query('DELETE FROM `problem_tag_map` WHERE `problem_id` = ' + this.id); |
|
await entityManager.query('DELETE FROM `article` WHERE `problem_id` = ' + this.id); |
|
await entityManager.query('DELETE FROM `submission_statistics` WHERE `problem_id` = ' + this.id); |
|
|
|
await this.destroy(); |
|
} |
|
}
|
|
|