/**
* 一个用于比较逗逼的测试任务类型,可以众筹大家在知道整数范围时,猜测出整数需要次数的分布。这里用户
* 猜测完毕之后可以得到偏大、偏小或者等于。
*
* 任务和作业本身的多态性是建立在`task.data`和`assignment.data`这两个无schema的数据上的。
*
* 就这个猜数字项目`task.data`主要包含了以下几个字段:
* - min {number} 最小的数值
* - max {number} 最大的数值
* - maxGuessTimes {number} 可选设置,默认无穷
* - submitMultipleTimes {boolean} 可选设置,是否允许一个人参与多次,默认false
* - signupMultipleTimes {boolean} 可选设置,是否允许一个人被拒后再次报名,默认false
* - noSignup {boolean} 可选设置,是否自动完成报名的步骤(即无需报名),默认false
* - submitAutoPass {boolean} 可选设置,是否自动审批任务(即任务提交即接受),默认false
* - dir {string} 内部使用,这是`uploads`目录下属于该工程的文件夹。
* - signedUsers {string[]} 内部使用,对于非noSignup,这是报名了的用户的列表
* - blockedUsers {string[]} 内部使用,对于非noSignup且非signupMultipleTimes,这是封禁用户的列表
* - exported {boolean} 内部使用,项目是否导出
*
* 而`assignment.data`主要包含了以下几个字段,而`assignment.valid`对于非signup类任务,表示能否继续猜:
* - signup {boolean} 表示这是一个用于注册的任务
* - answer {boolean} 对于非signup类任务,这是真正的答案
* - guesses {number} 对于非signup类任务,这是猜测的次数
* @module task-templates/guess-number
*/
const ajv = new (require('ajv'))();
const path = require('path');
const logger = require('winston');
const fs = require('fs');
const {errorsEnum, coreOkay, coreValidate, coreAssert} = require('../src/core/errors');
const {randomAlnumString, promisify} = require('../src/utils');
const rimraf = require('rimraf');
const exportedFilename = 'result.csv';
/**
* 将`task.data`转换为对象发送给对应可见性的人。这个函数会在postTaskData或者getTaskData返回
* undefined的时候默认调用。理论上,调用者要么是任务发布者,要么是订阅者
*
* 对本任务而言,而`exportedResult`只有任务拥有者能获得。
*
* @param task {object}
* @param auth {object}
* @return {object}
*/
function taskDataToPlainObject(task, auth) {
const isPublisher = auth && task.publisher.equals(auth.uid);
const result = {};
if (task.data) {
if (task.data.min !== undefined && task.data.max !== undefined) {
result.min = task.data.min;
result.max = task.data.max;
}
if (task.data.maxGuessTimes !== undefined)
result.maxGuessTimes = task.data.maxGuessTimes;
if (task.data.submitMultipleTimes !== undefined)
result.submitMultipleTimes = task.data.submitMultipleTimes;
if (task.data.signupMultipleTimes !== undefined)
result.signupMultipleTimes = task.data.signupMultipleTimes;
if (task.data.noSignup !== undefined)
result.noSignup = task.data.noSignup;
if (task.data.submitAutoPass !== undefined)
result.submitAutoPass = task.data.submitAutoPass;
if (isPublisher && task.data.exported)
result.exportedResult = '/uploads/' + task.data.dir + '/' + exportedFilename;
}
return result;
}
function cleanFiles(task, dir) {
if (task.data && task.data.dir) {
dir = path.join(dir, task.data.dir);
rimraf(dir, err => {
if (err) {
logger.error(`Failed to delete directory "${dir}".`);
logger.error(err);
}
});
}
}
/**
* 这是postTaskData AJAX请求执行之前的中间件。通常这里用以处理文件上传的multipart请求。缺省
* 情况下直接调用postTaskData。
*
* 对于本任务而言,没有文件上传。不做任何处理。
*
* @param ctx {object}
* @param next {function}
* @return {Promise<void>}
*/
async function postTaskDataMiddleware(ctx, next) {
await next();
}
const postTaskDataSchema = ajv.compile({
type: 'object',
required: ['min', 'max'],
properties: {
min: {type: 'integer', minimum: 0, maximum: 100},
max: {type: 'integer', minimum: 0, maximum: 100},
total: {type: 'integer', minimum: 1},
maxGuessTimes: {type: 'integer', minimum: 0, maximum: 100},
submitMultipleTimes: {type: 'boolean'},
signupMultipleTimes: {type: 'boolean'},
noSignup: {type: 'boolean'},
submitAutoPass: {type: 'boolean'}
},
additionalProperties: false
});
/**
* 提交任务的数据,这里已经确保提交者为任务的发布者,且任务处于`EDITING`状态。但该任务可能已经
* 提交过数据,因而应当清除以前的数据。原则上,这个过程结束后,应当重置任务的`total`、`remain`、
* `valid`和`data`状态。原则上,该过程如果没有返回错误码,`valid`应当为true。
*
* 如果这个函数没有返回值,则会依据情况调用taskDataToPlainObject。如果有返回值则会作为响应。
*
* 对于本任务而言,主要上传的数据是一些设置选项。
*
* @param task {object} 任务对象
* @param params {object} 请求的数据
* - query 请求的query
* - data {boolean} 是否返回数据
* - data 请求的data
* - min {number} 必须,最小的数值,0到100
* - max {number} 必须,最大的数值,0到100且min<max
* - total {number} 可选设置,全部的任务数目,大于0,默认无穷
* - maxGuessTimes {number} 可选设置,默认无穷
* - submitMultipleTimes {boolean} 可选设置,是否允许一个人参与多次,默认false
* - signupMultipleTimes {boolean} 可选设置,是否允许一个人被拒后再次报名,默认false
* - noSignup {boolean} 可选设置,是否无需报名,默认false
* - submitAutoPass {boolean} 可选设置,是否自动审批任务(即任务提交即接受),默认false
* @param global {object}
* @return {Promise<void>}
*/
async function postTaskData(task, params, global) {
const {config} = global;
coreValidate(postTaskDataSchema, params.data);
coreAssert(params.data.min < params.data.max,
errorsEnum.SCHEMA, 'Invalid min and max');
cleanFiles(task, config['upload-dir']);
const dirname = randomAlnumString(40);
const dir = path.join(config['upload-dir'], dirname);
await promisify(fs.mkdir)(dir);
params.data.dir = dir;
if (params.data.total !== undefined) {
task.total = params.data.total;
task.remain = params.data.total;
delete params.data.total;
} else {
task.total = -1;
task.remain = undefined;
}
if (!params.data.noSignup) {
params.data.signedUsers = [];
if (!params.data.signupMultipleTimes)
params.data.blockedUsers = [];
}
task.valid = true;
task.data = params.data;
task.markModified('data');
await task.save();
}
/**
* 获取任务的数据。这里已经确保要么提交者为任务的发布者,要么提交者为订阅者且任务处于`PUBLISHED`状态
* 如果这个函数没有返回值,则会依据情况调用postTaskDataSchema。如果有返回值则会作为响应。
*
* 对于本任务,如果不是发布者,就采用默认的调用taskDataToPlainObject。如果是订阅者且`PUBLISHED`,
* 还会返回在`userStatus`内返回以下几个字段:
* - signed:用户是否已经注册,对于noSignup的任务而言,这永远为真
* - blocked:用户是否被拒绝参加改任务,对于noSignup或signupMultipleTimes的任务而言,这永远为假
* - signing:对于非signed用户而言,这个属性表示用户是否正在报名
* - created:对于任务非submitMultipleTimes且用户signed,表示是否已经创建了一个作业
* @param task {object} 任务对象
* @param params {object}
* @param global {object}
* @return {Promise<object|void>}
*/
async function getTaskData(task, params, global) {
const {users, tasks, assignments} = global;
if (params.auth && (params.auth.role & users.roleEnum.SUBSCRIBER) &&
task.status === tasks.statusEnum.PUBLISHED) {
const userStatus = {};
if (task.data.noSignup) {
userStatus.signed = true;
userStatus.blocked = false;
} else {
userStatus.signed = task.data.signedUsers.indexOf(params.auth.uid) !== -1;
if (userStatus.signed === false)
userStatus.signing = (await assignments.findOne({
task: task._id,
subscriber: params.auth.uid,
status: assignments.statusEnum.SUBMITTED,
'data.signup': true
}).notDeleted()) !== null;
if (task.data.signupMultipleTimes)
userStatus.blocked = false;
else
userStatus.blocked = task.data.blockedUsers.indexOf(params.auth.uid) !== -1;
}
if (userStatus.signed && !task.data.submitMultipleTimes)
userStatus.created = (await assignments.findOne({
task: task._id,
subscriber: params.auth.uid,
'data.signup': {$ne: true}
}).notDeleted()) !== null;
return coreOkay({
data: Object.assign({userStatus}, taskDataToPlainObject(task, params.auth))
});
}
}
/**
* 将`assignment.data`转换为对象发送给对应可见性的人。这个函数会在createAssignment、
* getAssignmentData或者getAssignmentData返回undefined的时候默认调用。
* 理论上,调用者是订阅者或者发布者。
*
* 对本任务而言,只有作业是一个非报名作业才返回数据,如下:
* - guessTimes {number} 猜测次数
* - finished {boolean} 是否完成
* - answer {boolean} 答案(完成之后才返回)
* @param assignment {object}
* @param auth {object}
* @return {object}
*/
function assignmentDataToPlainObject(assignment, auth) {
if (!assignment.data || assignment.data.signup)
return {};
const result = {
guessTimes: assignment.data.guesses.length,
finished: assignment.valid
};
if (assignment.valid)
result.answer = assignment.data.answer;
return result;
}
const createAssignmentSchema = ajv.compile({
type: 'object',
properties: {
signup: {type: 'boolean'}
},
additionalProperties: false
});
/**
* 创建任务,这里已经确保提交者为订阅者,且任务处于`PUBLISHED`状态。原则上,这个过程结束后,
* 应当设置好`valid`、`status`、`summary`、`data`的字段。如果该函数缺省,则会创建一个
* 不valid、编辑状态、无`summary`和`data`字段的任务。assignment会被保存。
*
* 如果这个函数没有返回值,则依据情况调用assignmentDataToPlainObject。
* 如果有返回值则会作为响应。
*
* 对于本任务而言,主要确认创建的是一个报名作业还是真正的猜数字作业。
*
* @param task {object} 任务对象
* @param assignment {object} 作业对象
* @param params {object} 请求的数据
* - query 请求的query
* - populate {boolean} 是否返回
* - data {boolean} 是否返回数据
* - data 请求的data
* - task {string} 任务
* - data {object} 额外的数据
* - signup {boolean} 是否是报名任务
* @param global {object}
* @return {Promise<void>}
*/
async function createAssignment(task, assignment, params, global) {
const {assignments} = global;
if (params.data.data !== undefined)
coreValidate(createAssignmentSchema, params.data.data);
const signup = params.data.data && params.data.data.signup;
if (signup) {
coreAssert(!task.data.noSignup, errorsEnum.INVALID, 'Task requires no signup');
coreAssert(task.data.signedUsers.indexOf(params.auth.uid) === -1,
errorsEnum.INVALID, 'User has already signed up');
coreAssert(task.data.signupMultipleTimes ||
task.data.blockedUsers.indexOf(params.auth.uid) === -1,
// eslint-disable-next-line
errorsEnum.INVALID, 'User has been blocked');
coreAssert((await assignments.findOne({
task: task._id,
subscriber: params.auth.uid,
status: assignments.statusEnum.SUBMITTED,
'data.signup': true
}).notDeleted()) === null, errorsEnum.INVALID, 'User has already created a signup request');
assignment.valid = true;
assignment.summary = '报名';
assignment.status = assignments.statusEnum.SUBMITTED;
assignment.data = {signup: true};
assignment.markModified('data');
} else {
coreAssert((task.total < 0 || task.remain > 0) &&
(task.deadline === false || Date.now() <= task.deadline.getTime()),
// eslint-disable-next-line
errorsEnum.INVALID, 'Task has completed');
coreAssert(task.data.noSignup || task.data.signedUsers.indexOf(params.auth.uid) !== -1,
errorsEnum.INVALID, 'User has not signed up');
coreAssert(task.data.submitMultipleTimes || (await assignments.findOne({
task: task._id,
subscriber: params.auth.uid,
'data.signup': {$ne: true}
}).notDeleted()) === null, errorsEnum.INVALID, 'User has already created an assignment');
assignment.summary = '未完成,猜测0次';
assignment.data = {
guesses: [],
answer: Math.round(Math.random() * (task.data.max - task.data.min + 1)) + task.data.min
};
assignment.markModified('data');
}
}
/**
* 这个函数是作业状态更改的钩子,确保作业的状态发生更改,可能的更改包括订阅者从编辑到提交,同时作业必须为valid,
* 以及发布者从提交到接受或拒绝。assignment会被保存。
*
* 本任务主要是处理自动pass和验证通过的逻辑的。
*
* @param assignment {object}
* @param params
* @param global
* @return {Promise<void>}
*/
async function assignmentStatusChanged(assignment, params, global) {
if (assignment.data.signup) {
const {tasks, assignments} = global;
const task = await tasks.findById(assignment.task).notDeleted().select('+data');
coreAssert(task, errorsEnum.INVALID, 'Task deleted');
if (assignment.status === assignments.statusEnum.ADMITTED) {
task.data.signedUsers.push(assignment.subscriber.toString());
task.markModified('data.signedUsers');
await task.save();
} else if (!task.data.signupMultipleTimes) {
task.data.blockedUsers.push(assignment.subscriber.toString());
task.markModified('data.signedUsers');
await task.save();
}
} else {
const {tasks, assignments} = global;
const task = await tasks.findById(assignment.task).notDeleted().select('+data');
coreAssert(task, errorsEnum.INVALID, 'Task deleted');
if (task.data.submitAutoPass && assignment.status === assignments.statusEnum.SUBMITTED)
assignment.status = assignments.statusEnum.ADMITTED;
if (task.total > 0 && assignment.status === assignments.statusEnum.ADMITTED)
await tasks.findOneAndUpdate({_id: task._id}, {$inc: {'task.remain': -1}}).notDeleted();
}
}
/**
* 这是postAssignmentData AJAX请求执行之前的中间件。通常这里用以处理文件上传的multipart请求。
* 缺省情况下直接调用postAssignmentData。
*
* 对于本任务的作业而言,没有文件上传。不做任何处理。
*
* @param ctx {object}
* @param next {function}
* @return {Promise<void>}
*/
async function postAssignmentDataMiddleware(ctx, next) {
await next();
}
const postAssignmentDataSchema = ajv.compile({
type: 'object',
required: ['guess'],
properties: {
guess: {type: 'integer', minimum: 0, maximum: 100}
},
additionalProperties: false
});
/**
* 提交作业的数据,这里已经确保提交者为作业的订阅者,且作业处于`EDITING`状态。但该作业可能已经
* 提交过数据,因而应当清除以前的数据。原则上,这个过程结束后,应当重置作业的`summary`、
* `valid`和`data`状态。原则上,该过程如果没有返回错误码,`valid`应当为true。
*
* 如果这个函数没有返回值,则会依据情况调用assignmentDataToPlainObject。如果有返回值则会作为响应。
*
* 对于本任务的作业而言,情况略有不同,这是有状态的,不可逆的故而不清除数据。主要上传的数据是猜的答案。
* 返回的数据还会包含一个额外的字段:
* - compare {number} -1:偏小,0:猜对,1:偏大
* @param assignment {object} 任务对象
* @param params {object} 请求的数据
* - query 请求的query
* - data {boolean} 是否返回数据
* - data 请求的data
* - guess {number} 必须
* @param global {object}
* @return {Promise<object>}
*/
async function postAssignmentData(assignment, params, global) {
coreValidate(postAssignmentDataSchema, params.data);
coreAssert(!assignment.valid, errorsEnum.INVALID, 'Assignment already finished');
assignment.data.guesses.push(params.data.guess);
let compare;
if (params.data.guess === assignment.data.answer) {
compare = 0;
assignment.valid = true;
assignment.summary = `已完成,猜测${assignment.data.guesses.length}次,正确`;
} else if (params.data.guess < assignment.data.answer)
compare = -1;
else
compare = 1;
if (compare !== 0) {
const {tasks} = global;
const task = await tasks.findById(assignment.task).notDeleted().select('+data');
coreAssert(task, errorsEnum.INVALID, 'Task deleted');
if (task.data.maxGuessTimes === undefined ||
assignment.data.guesses.length < task.data.maxGuessTimes)
assignment.summary = `未完成,猜测${assignment.data.guesses.length}次`;
else {
assignment.valid = true;
assignment.summary = `已完成,猜测${assignment.data.guesses.length}次,错误`;
}
}
assignment.markModified('data');
await assignment.save();
return coreOkay({
data: params.query.data === 'true'
? Object.assign({compare}, assignmentDataToPlainObject(assignment, params.auth))
: {compare}
});
}
/**
* 获取作业的数据。这里已经确保要么提交者为作业的订阅者或发布者。如果这个函数没有返回值,
* 则会依据情况调用assignmentDataToPlainObject。如果有返回值则会作为响应。
*
* 对于本任务,就采用默认的assignmentDataToPlainObject。
* @param assignment {object} 任务对象
* @param params {object}
* @param global {object}
* @return {Promise<void>}
*/
async function getAssignmentData(assignment, params, global) {}
module.exports = {
meta: {
id: 'guess-number',
name: '猜数字',
description: '这只是一个测试,证明我们好像是可以发布任务,报名参加任务。'
},
taskDataToPlainObject,
postTaskDataMiddleware,
postTaskData,
getTaskData,
assignmentDataToPlainObject,
createAssignment,
assignmentStatusChanged,
postAssignmentDataMiddleware,
postAssignmentData,
getAssignmentData
};