feat(big-question): 综合大题 Excel 模板下载 + 批量导入(R73)
新增 GET /api/admin/big-questions/:group/template —— 生成自解释模板: 数据 sheet 排第 1(导入侧按 SheetNames[0] 解析,是承重契约)+「填写说明」sheet, 含列名含义/状态建议/常见被拒原因,让测试人员下载即会用,不依赖外部文档。 新增 POST /api/admin/big-questions/:group/import-excel —— 逐行调用 service.bigQuestion.createQuestion,标题长度/三小问/分值/泄题检测(detectAnswerLeak) 全部自动继承,与手工录入同一入口同一校验;批量导入绝不成为绕过防呆的后门。 组别以 URL :group 为准,忽略表格里的组别列;同组内标题重复即跳过并报错; 状态缺省 0(停用),批量录入的题未经人工校对不直接进抽题池。 客观题模板 buildQuestionTemplate 同步加「填写说明」sheet:数据 sheet 仍排第 1、 原 8 列不变,故 importQuestionsFromExcel 的 SheetNames[0] 解析不受影响(已离线验证)。 路由顺序硬约束:含具体段的 template / import-excel 必须排在通配段 /:id 之前。
This commit is contained in:
@@ -1783,6 +1783,83 @@ class AdminController extends Controller {
|
||||
* ⚠️ 路由必须注册在 `/big-questions/:group/:id`(PUT/DELETE)之前或不同方法,
|
||||
* 否则 `export` 会被当成 :id 吞掉(见 router.js 注释)。
|
||||
*/
|
||||
/**
|
||||
* 下载综合大题 Excel 导入模板(R73)
|
||||
* GET /api/admin/big-questions/:group/template
|
||||
*/
|
||||
async downloadBigQuestionTemplate() {
|
||||
const { ctx, service } = this;
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
try {
|
||||
const { group } = ctx.params;
|
||||
if (!this._validateGroup(group)) return;
|
||||
|
||||
const buffer = await service.bigQuestion.buildTemplate();
|
||||
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
// ⚠️ 只调 ctx.attachment(原名):koa 自动做 RFC5987 编码,自行 encodeURIComponent 会双重编码
|
||||
ctx.attachment(`综合大题导入模板_${group}.xlsx`);
|
||||
ctx.body = buffer;
|
||||
} catch (err) {
|
||||
ctx.logger.error('下载大题导入模板失败:', err);
|
||||
ctx.body = ctx.helper.error(500, '生成模板失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合大题 Excel 批量导入(R73)
|
||||
* POST /api/admin/big-questions/:group/import-excel
|
||||
* multipart/form-data: file 字段为 .xlsx/.xls
|
||||
* sheet:首个 sheet 为数据表(表头在第 1 行),可选携带「填写说明」sheet
|
||||
*/
|
||||
async importBigQuestionsExcel() {
|
||||
const { ctx, service, app } = this;
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
const { group } = ctx.params;
|
||||
if (!this._validateGroup(group)) return;
|
||||
|
||||
let tempFilePath = null;
|
||||
try {
|
||||
const stream = await ctx.getFileStream();
|
||||
if (!stream) {
|
||||
ctx.body = ctx.helper.error(400, '未接收到文件');
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(stream.filename).toLowerCase();
|
||||
if (!['.xlsx', '.xls'].includes(ext)) {
|
||||
ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式');
|
||||
return;
|
||||
}
|
||||
|
||||
const tempDir = path.join(app.baseDir, 'app/public/tmp/excel');
|
||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||
tempFilePath = path.join(tempDir, `big_questions_import_${Date.now()}${ext}`);
|
||||
const writeStream = fs.createWriteStream(tempFilePath);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.pipe(writeStream);
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
stream.on('error', reject);
|
||||
});
|
||||
|
||||
const result = await service.bigQuestion.importFromExcel(tempFilePath, { group });
|
||||
if (!result.ok) {
|
||||
ctx.body = ctx.helper.error(400, result.message);
|
||||
return;
|
||||
}
|
||||
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`);
|
||||
} catch (err) {
|
||||
ctx.logger.error('综合大题 Excel 导入失败:', err);
|
||||
const msg = (err && err.message) ? err.message : '未知错误';
|
||||
ctx.body = ctx.helper.error(500, '导入失败:' + msg);
|
||||
} finally {
|
||||
// 无论成功/失败都清临时文件:题目内容属敏感数据,不留副本
|
||||
if (tempFilePath) { try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ } }
|
||||
}
|
||||
}
|
||||
|
||||
async exportBigQuestions() {
|
||||
const { ctx, service } = this;
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
Reference in New Issue
Block a user