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:
2026-10-08 10:08:41 +08:00
parent d44d466a90
commit 154338e5dd
4 changed files with 324 additions and 0 deletions

View File

@@ -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;