From 78f34ea9f5eb4136248d7844822dfe6d53e90e68 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Thu, 22 Oct 2026 09:41:51 +0800 Subject: [PATCH] =?UTF-8?q?R194+R195:=20=E5=B0=8F=E7=BB=84=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6=E4=BD=9C=E5=93=81=E5=88=97=E8=A1=A8=20/=20=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=90=8D=E5=B8=A6=E5=B0=8F=E7=BB=84=E7=BC=96=E5=8F=B7?= =?UTF-8?q?=20/=20includeIdle=20=E5=8F=A3=E5=BE=84=20/=20deleteNews=20?= =?UTF-8?q?=E8=BF=9E=E5=B8=A6=E6=B8=85=E7=90=86=E9=99=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service/admin.js · getScores & getExamSubmissions 支持 includeIdle(默认隐藏「从未参赛」报名者), 成绩导出与批量打包强制全量(includeIdle:true),页面开关不影响导出内容 · 新增 getTeamFolders:小组编号口径统一走 resolveTeamLabels,⛔ 不直接展示 team_id · 单个/批量下载包内名统一走 buildTeamDownloadName(中职高职 <小组编号>-<姓名>-<原名>) · 🔴 deleteNews:原实现只 News.destroy 不删盘 ⇒ 每轮公示表测试留 4 个孤儿 xlsx。 改为「先删库后删盘」并连带清理 attachment_url 附件;新增 _removeNewsFile 三重安全闸门 (仅 /public/news/ 单段文件名 + 自产命名前缀 + path.resolve 不越界)与幂等语义, 公示表覆盖写复用同一入口(白名单收窄到 news_file_publicity_) - lib/team_folder.js:新增 buildTeamDownloadName + MAX_DOWNLOAD_NAME=150(防 Windows 255 上限) - controller/admin.js:透传 includeIdle;batchDownload 接住 bizCode=EMPTY(业务态返 404 非 500); 中职高职单个大题下载名改走 buildTeamDownloadName 验证:真接口探针 12/12(双清 + 幂等 + 三重闸门);变异 M1(不删盘)1 红、M2(去闸门)3 红, 还原后 12/12 复绿 --- app/controller/admin.js | 43 +++++++++- app/lib/team_folder.js | 59 ++++++++++++++ app/service/admin.js | 175 +++++++++++++++++++++++++++++++++++----- 3 files changed, 253 insertions(+), 24 deletions(-) diff --git a/app/controller/admin.js b/app/controller/admin.js index f40710a..fe99018 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -15,6 +15,9 @@ const { recordAudit, ACTIONS: AUDIT_ACTIONS } = require('../lib/auditLog'); // 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入 // TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。 const { requireTestPassword } = require('../lib/passwordPolicy'); +// 🔴 R195:下载文件名的「小组编号」前缀与拼接规则唯一 SoT(与落盘目录名同源)。 +// 中职/高职大题单个下载与批量 ZIP 包内名**必须**共用 buildTeamDownloadName。 +const { buildTeamDownloadName } = require('../lib/team_folder'); /** * 管理控制器 - AdminController @@ -417,9 +420,12 @@ class AdminController extends Controller { if (!this._validateGroup(group, true)) return; // 中职/高职组使用简化版查询(contestants + exam_submissions) + // R195:默认只返回「参赛过的人」(有作答 / 有大题文件 / 已交卷); + // `?includeIdle=1` 才返回全部报名者(前端「显示未参赛」开关)。 const isExamGroup = group === 'zhongzhi' || group === 'gaozhi'; + const includeIdle = ctx.query.includeIdle === '1' || ctx.query.includeIdle === 'true'; const submissions = isExamGroup - ? await service.admin.getExamSubmissions(group) + ? await service.admin.getExamSubmissions(group, { includeIdle }) : await service.admin.getSubmissions(group); ctx.body = ctx.helper.success({ group, @@ -546,6 +552,17 @@ class AdminController extends Controller { ctx.helper.safeCleanup(path.join(zipRoot, group), { label: 'batchDownload-zipDir' }); }); } catch (err) { + // 🔴 R195 修既有缺陷:「该组一个已上传文件都没有」是**正常业务态**,不是服务故障。 + // `service.admin.batchDownloadZip` 为此专门抛 `bizCode:'EMPTY'`,但这里原先没有分支 + // 接住它 ⇒ 落到兜底 500「批量下载失败」,管理端完全看不出真实原因, + // 且每点一次下载就刷一条 ERROR 日志(噪音淹没真故障)。 + // ⚠️ 不能用 HTTP 4xx:本项目约定 `helper.error()` 不改状态码,业务错 = 200 + code≠0。 + if (err && err.bizCode === 'EMPTY') { + ctx.logger.warn(`[batchDownload] 组 ${ctx.params.group} 暂无已上传文件(业务 404)`); + ctx.body = ctx.helper.error(404, err.message || '该组暂无已上传的作品文件'); + return; + } + ctx.logger.error('批量下载失败:', err); // 如果 archiver 不可用,返回友好错误 @@ -794,7 +811,10 @@ class AdminController extends Controller { const { group } = ctx.params; if (!this._validateGroup(group, true)) return; - const scores = await service.admin.getScores(group); + // R195:默认隐藏「从未参赛」的报名者(中职/高职按小组参赛,未上场组员全 0 分占行); + // `?includeIdle=1` 返回全量(前端「显示未参赛」开关)。 + const includeIdle = ctx.query.includeIdle === '1' || ctx.query.includeIdle === 'true'; + const scores = await service.admin.getScores(group, { includeIdle }); ctx.body = ctx.helper.success({ list: scores }); } catch (err) { ctx.logger.error('获取评分列表失败:', err); @@ -1600,8 +1620,23 @@ class AdminController extends Controller { return; } - const fileName = wordFileName || path.basename(filePath); - filePathCache.setFull(wordCacheKey, wordFilePath, wordFileName); + let fileName = wordFileName || path.basename(filePath); + if (isExamGroup) { + // 🔴 R195:中职/高职大题下载名 = `<小组编号>-<姓名>-<原文件名>` + // 小组编号口径走 resolveTeamLabels(teamNumber → 未编组_), + // 与「选手管理 / 提交记录」列表展示、作品落盘目录**同源**,⛔ 不取 contestants.team_id。 + // 缓存里存的是**最终名**,故命中缓存时无需再查库。 + const teamFolders = await service.admin.getTeamFolders(group, [id]); + const tf = teamFolders.get(String(id)) || {}; + fileName = buildTeamDownloadName({ + teamNumber: tf.teamNumber, + teamUid: tf.teamUid, + aggregationUid: id, + personName: tf.name, + originalName: fileName, + }); + } + filePathCache.setFull(wordCacheKey, wordFilePath, fileName); // 🔴 X-Accel-Redirect 根治:生产经 nginx 内部重定向吐文件,开发回退流式。 // 文件名编码与路径穿越防护、文件 IO 并发护栏统一在 _sendFile 内处理(见其注释)。 await this._sendFile( diff --git a/app/lib/team_folder.js b/app/lib/team_folder.js index 40af6ea..14ff663 100644 --- a/app/lib/team_folder.js +++ b/app/lib/team_folder.js @@ -123,10 +123,69 @@ function teamFolderRelative(group, folderName) { .join('/'); } +/** + * 下载文件名主体(不含扩展名)的字符上限。 + * + * 🔴 为什么要有上限:下载名 = 小组编号(≤60) + 姓名(≤60) + 原文件名(≤120), + * 三者直接拼接可到 240 字符。Windows 资源管理器对单文件名上限 255 **字符** + * (NTFS 255 UTF-16 单元),240 已贴边,再加上浏览器自动加的 `(1)` 后缀就会失败。 + * 故统一收敛到 150,保证「编号 + 姓名 + 关键文件名」都能看见,又不触系统上限。 + */ +const MAX_DOWNLOAD_NAME = 150; + +/** + * 拼接「管理端下载文件名」—— 中职/高职大题(Word)单个下载与批量 ZIP 包内名共用。 + * + * 格式:`<小组编号>-<姓名>-<原文件名>`(R195 需求:小组编号放最前) + * 例:`BRICS2026-ST-027-94001-张三-大题作答.docx` + * + * 🔴 单一口径:小组编号三态(teamNumber → `未编组_` → `未编组_`) + * 完全复用 resolveTeamFolderName,与本组上传落盘目录名**必然一致** + * ⇒ 管理员把下载文件解压回工作目录时,文件名前缀可直接对上作品目录。 + * ⛔ 不得在 controller / service 各拼一套(尤其是「有队伍但 teams 表缺元信息」这一档, + * 两处拼法分叉后会出现「同一个人下载出两个不一样的前缀」)。 + * + * @param {Object} p + * @param {?string} p.teamNumber - teams.team_number || teams.team_id + * @param {?number} p.teamUid - 队伍号(组内唯一) + * @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底,防多人同名前缀重号) + * @param {?string} p.personName - 选手姓名(缺省则跳过该段) + * @param {?string} p.originalName - 原文件名(选手上传名) + * @param {string} [p.fallbackStem] - 原文件名为空时的兜底主体 + * @returns {string} 安全文件名(非空、无路径分隔符、长度受限) + */ +function buildTeamDownloadName({ + teamNumber, + teamUid, + aggregationUid, + personName, + originalName, + fallbackStem = '大题作答', +} = {}) { + const prefix = resolveTeamFolderName({ teamNumber, teamUid, aggregationUid }); + const person = personName === null || personName === undefined + ? '' + : sanitizeSegment(personName, ''); + const base = sanitizeFileName(originalName, fallbackStem); + + // 拆扩展名:截断只能砍主体,⛔ 不能把 `.docx` 砍掉(砍掉后浏览器会存成未知类型) + const dot = base.lastIndexOf('.'); + const stem = dot > 0 ? base.slice(0, dot) : base; + const ext = dot > 0 ? base.slice(dot) : ''; + + const head = [prefix, person].filter(Boolean).join('-'); + // 预留 1 个字符给分隔符 '-' + const room = MAX_DOWNLOAD_NAME - head.length - ext.length - 1; + const finalStem = stem.length > room ? stem.slice(0, Math.max(1, room)).trim() : stem; + return `${head}-${finalStem}${ext}`; +} + module.exports = { MAX_SEGMENT, + MAX_DOWNLOAD_NAME, sanitizeSegment, sanitizeFileName, resolveTeamFolderName, teamFolderRelative, + buildTeamDownloadName, }; diff --git a/app/service/admin.js b/app/service/admin.js index f032751..4bb12e7 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -21,6 +21,7 @@ const { inspectIdNumber } = require('../lib/idCard'); const { resolveTeamFolderName, teamFolderRelative, + buildTeamDownloadName, } = require('../lib/team_folder'); /** @@ -1029,11 +1030,13 @@ class AdminService extends Service { // ==================== 提交记录管理 ==================== /** - * 获取考试答题记录(查询组别对应的提交表) + * 获取考试组提交记录(查询组别对应的提交表;中职/高职一行一个选手) * @param {string} group - 组别 + * @param {Object} [options] + * @param {boolean} [options.includeIdle=false] - 是否包含「从未参赛」的报名者(默认隐藏) * @returns {Promise} 答题记录 */ - async getExamSubmissions(group) { + async getExamSubmissions(group, options = {}) { const { ctx } = this; const SubmissionModel = group === 'zhongzhi' @@ -1108,7 +1111,7 @@ class AdminService extends Service { Math.min(timeRow && timeRow.choice_count != null ? timeRow.choice_count : 50, availableChoice) + Math.min(timeRow && timeRow.judge_count != null ? timeRow.judge_count : 20, availableJudge); - return contestants.map(c => { + const rows = contestants.map(c => { // 归集键(含歧义消解后的兜底键);首元素必为主键 const candidateKeys = aggKeysByContestant[String(c.id)] || [String(c.id)]; const subs = candidateKeys.flatMap(k => submissionsByUser[k] || []); @@ -1157,6 +1160,12 @@ answeredCount, examScore: autoScore, }; }); + + // 🔴 R195:默认隐藏「从未参赛」的报名者(与 getScores 完全同一判据,⛔ 两边各写一套)。 + // 「考生答题记录 / 提交记录」表同样一行一个选手,未上场组员会占满屏幕且全为 0。 + // 需要全量时前端传 includeIdle=1。 + if (options.includeIdle) return rows; + return rows.filter(r => r.answeredCount > 0 || r.hasWordFile || r.formalSubmitted); } /** @@ -1365,7 +1374,9 @@ answeredCount, // 中职/高职组:客观题(系统自动评分) + 综合大题(人工阅卷) // 说明:大题分落库列名为 contestants.word_score(历史沿用,语义已变为「综合大题得分」), // 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。 - const submissions = await this.getExamSubmissions(group); + // ⚠️ 导出保持**全量**(含未参赛报名者):成绩导出用于存档/公示核对, + // 与页面「默认隐藏未参赛」是两种用途,⛔ 不要让页面开关改变导出内容。 + const submissions = await this.getExamSubmissions(group, { includeIdle: true }); return { // 🔴 R178(B④):原「是否提交」列取 `answeredCount > 0`,与页面/接口口径(submit_time) // 不一致,同一份数据导出与页面显示会互相矛盾。现在拆为「是否交卷」(submit_time) @@ -1679,6 +1690,46 @@ answeredCount, return zipFilePath; } + /** + * 批量取选手的「小组编号」标签(下载文件名前缀用) + * + * 口径唯一来源 = `resolveTeamLabels` → `resolveTeamFolderName`: + * · `teams.team_number || teams.team_id`(人类可读报名编号) + * · 有队伍归属但 teams 表缺元信息 ⇒ `未编组_`(实测中职张明远 team_uid=1 无 teams 行) + * · 连队伍归属都没有 ⇒ `未编组_`(每人独立,防撞名) + * ⛔ 不可直接展示 / 拼接 `contestants.team_id`(内部数字,与报名编号两套序列)。 + * + * @param {string} group + * @param {Array} contestantIds + * @returns {Promise>} + */ + async getTeamFolders(group, contestantIds) { + const { ctx } = this; + const ids = (contestantIds || []) + .filter(x => x !== null && x !== undefined && String(x).trim() !== ''); + const out = new Map(); + if (ids.length === 0) return out; + + const contestants = await ctx.model.Contestant.findAll({ + where: { id: ids, group }, + // team_id 供 resolveTeamLabels 在「aggregation.team_uid 缺失」时兜底 + attributes: ['id', 'name', 'group', 'team_id'], + raw: true, + }); + if (contestants.length === 0) return out; + + const labelMap = await resolveTeamLabels(ctx, contestants); + for (const c of contestants) { + const label = labelMap.get(String(c.id)) || {}; + out.set(String(c.id), { + teamNumber: label.teamNumber || null, + teamUid: label.teamUid === undefined ? null : label.teamUid, + name: c.name || '', + }); + } + return out; + } + /** * 批量下载某组所有提交文件,打包为 ZIP * @@ -1686,7 +1737,10 @@ answeredCount, * 旧实现把所有文件平铺在 zip 根下,命名为 `姓名-word-原名`, * 两个文件(Word/PPT)混在一堆同类文件里,评分时无法判断哪两个属于同一小组。 * 新结构:`<小组编号>/<原始文件名>`,与管理端「单个小组压缩包」完全同构。 - * 中职/高职组(Word 答题答案)保持原有平铺命名,不受影响。 + * + * 🔴 R195 改造:中职/高职组(Word 大题答案)包内名改为 + * `<小组编号>-<姓名>-<原文件名>`(原为 `<姓名>-<原文件名>`,小组编号无处体现)。 + * 格式与单个下载共用 `buildTeamDownloadName`,⛔ 不在此另拼一套。 * * @param {string} group - 组别 * @returns {Promise} ZIP 文件路径 @@ -1697,7 +1751,8 @@ answeredCount, // 获取该组的所有提交记录(考试组与作品组数据形状不同) const isExam = group === 'zhongzhi' || group === 'gaozhi'; const submissions = isExam - ? await this.getExamSubmissions(group) + // ⚠️ 打包取**全量**:是否入包由「有无 word 文件」决定,与页面过滤无关。 + ? await this.getExamSubmissions(group, { includeIdle: true }) : await this.getSubmissions(group); // works 列表提供「选手 → 小组目录名」映射(口径与上传落盘同源) @@ -1707,6 +1762,11 @@ answeredCount, folderByContestant.set(String(w.contestantId), w.teamFolder); } + // 考试组:小组编号映射(R195)。口径 = resolveTeamLabels,与前端展示 / 落盘目录同源。 + const teamFolders = isExam + ? await this.getTeamFolders(group, submissions.map(s => s.contestantId)) + : new Map(); + const entries = []; const usedNames = new Set(); /** 同名条目去重(历史数据里同组两人都传过时会撞名) */ @@ -1727,18 +1787,22 @@ answeredCount, return cand; }; - // 本科/教师组需要 account.name_cn;考试组使用 getExamSubmissions 的 userName for (const item of submissions) { - const account = item.account || {}; - const userName = (isExam ? (item.userName || '未知') : (account.name_cn || '未知')).replace(/[\\/:*?"<>|]/g, '_'); - if (isExam) { - // 中职/高职组:收集 Word 答案文件(保持原平铺命名) + // 中职/高职组:Word 大题答案,平铺,名 = 小组编号-姓名-原文件名(R195) if (item.wordFilePath) { const fullPath = path.join(app.baseDir, 'app/public', item.wordFilePath); if (fs.existsSync(fullPath)) { const origName = item.wordFileName || path.basename(fullPath); - entries.push({ absPath: fullPath, zipName: uniqueZipName(`${userName}-${origName}`) }); + const tf = teamFolders.get(String(item.contestantId)) || {}; + const zipName = buildTeamDownloadName({ + teamNumber: tf.teamNumber, + teamUid: tf.teamUid, + aggregationUid: item.contestantId, + personName: tf.name || item.userName, + originalName: origName, + }); + entries.push({ absPath: fullPath, zipName: uniqueZipName(zipName) }); } } } else { @@ -2352,11 +2416,13 @@ answeredCount, // ==================== 评分查询 ==================== /** - * 获取某组评分列表 + * 获取成绩列表(管理端「成绩管理 → 成绩总览」) * @param {string} group - 组别 + * @param {Object} [options] + * @param {boolean} [options.includeIdle=false] - 是否包含「从未参赛」的报名者(默认隐藏) * @returns {Promise} 评分列表 */ - async getScores(group) { + async getScores(group, options = {}) { const { ctx } = this; const isExam = group === 'zhongzhi' || group === 'gaozhi'; const contestants = await ctx.model.Contestant.findAll({ @@ -2478,6 +2544,16 @@ answeredCount, }); } + // 🔴 R195:默认隐藏「从未参赛」的报名者。 + // 中职/高职是**小组参赛**:一组报名多人、实际只一人上场,其余组员在成绩表里 + // 各占一行 0 分 —— 管理员按小组核对成绩时这些行纯属噪音。 + // 判据 = 有作答 || 有大题文件 || 已正式交卷(任一即算「参赛过」); + // ⛔ 不用「已激活」判断:激活 ≠ 参赛(存在激活后从未进考场的小组)。 + // 需要全量时前端传 includeIdle=1(「显示未参赛」开关)。 + // ⚠️ 只对考试组生效:本科/教师的提交判据是「上传作品」,口径不同,不在本次范围。 + if (isExam && !options.includeIdle) { + return list.filter(it => it.answeredCount > 0 || it.hasWordFile || it.formalSubmitted); + } return list; } @@ -2899,11 +2975,10 @@ answeredCount, attachment_url: fileUrl, attachment_name: built.fileName, }); - // 旧附件清理:只删本流程自己产出的文件(前缀 + 目录双重限定,绝不误删人工上传的图/文件) - if (oldUrl && oldUrl !== fileUrl && oldUrl.indexOf('/public/news/news_file_publicity_') === 0) { - ctx.helper.safeCleanup(path.join(this.app.baseDir, 'app', oldUrl.replace(/^\//, '')), { - label: 'publicity-draft-old', - }); + // 旧附件清理:复用 _removeNewsFile 的安全闸门,并把可删范围**收窄**到本流程 + // 自己产出的公示表(`news_file_publicity_` 前缀),绝不误删人工上传的图/文件。 + if (oldUrl && oldUrl !== fileUrl) { + this._removeNewsFile(oldUrl, 'publicity-draft-old', /^news_file_publicity_/); } results.push({ group: g, newsId: exist.id, action: 'updated', fileUrl }); } else { @@ -3029,12 +3104,72 @@ answeredCount, } /** - * 删除新闻 + * 安全删除 `app/public/news/` 下的**本系统产出**文件(新闻图片 / 新闻附件 / 公示表)。 + * + * 🔴 R195b(2026-10-22):`deleteNews` 原来只 `News.destroy`,**不删盘** ⇒ 每轮公示表 + * 测试都在 `public/news/` 留下 4 个孤儿 xlsx(实测累积十余个,DB 已无引用)。 + * 这里把「库/盘双清」收成唯一入口,供 deleteNews 与公示表覆盖写复用。 + * + * 安全闸门(三重,任一不过即跳过并告警): + * ① 必须是 `/public/news/<单段文件名>`(拒绝子路径、外域、其它目录); + * ② 文件名必须匹配本系统自产的命名前缀(见 controller/admin.js 的三处 upload); + * ③ `path.resolve` 归一后必须仍落在 `app/public/news` 内(防 `..` 绕过)。 + * + * ⛔ 绝不删人工放到其它目录的文件;⛔ 不删正文内嵌图(见 deleteNews 的边界说明)。 + * + * @param {string} url - 形如 `/public/news/news_file_1792.xlsx` + * @param {string} label - 日志标签 + * @param {RegExp} [allow] - 允许的 basename 前缀正则(默认覆盖图片+附件+公示表) + * @returns {'removed'|'missing'|'skipped'} 与 helper.safeCleanup 同口径(永不抛错) + */ + _removeNewsFile(url, label = 'deleteNews', allow = /^(news_|news_file_)/) { + const { ctx } = this; + if (!url || typeof url !== 'string') return 'missing'; + + const m = /^\/public\/news\/([^/\\]+)$/.exec(url.trim()); + if (!m) { + ctx.logger.warn('[%s] 拒绝删除:非 news 目录或含子路径(安全闸门): %s', label, url); + return 'skipped'; + } + const name = m[1]; + if (name.indexOf('..') >= 0 || !allow.test(name)) { + ctx.logger.warn('[%s] 拒绝删除:非本系统自产命名(安全闸门): %s', label, url); + return 'skipped'; + } + + const dir = path.resolve(path.join(this.app.baseDir, 'app/public/news')); + const absPath = path.resolve(path.join(dir, name)); + if (path.dirname(absPath) !== dir) { + ctx.logger.warn('[%s] 拒绝删除:解析后越界(安全闸门): %s ⇒ %s', label, url, absPath); + return 'skipped'; + } + return ctx.helper.safeCleanup(absPath, { label }); + } + + /** + * 删除新闻(连带清理附件文件) + * * @param {number} id - 新闻 ID + * @returns {Promise} 删除的行数(0 = 本就不存在,幂等) */ async deleteNews(id) { const { ctx } = this; + const news = await ctx.model.News.findByPk(id); + // 幂等:行不存在与「删了 0 行」同语义,且**不碰磁盘**(避免 id 猜中别人的附件) + if (!news) return 0; + + const attachmentUrl = news.attachment_url; await ctx.model.News.destroy({ where: { id } }); + + // 🔴 顺序铁律:**先删库后删盘**。库是 SoT —— 若反过来先删盘、再删库失败,就留下 + // 「DB 有行但文件已没了」的死链(前端点下载 404);而当前顺序即使删盘失败, + // 最坏也只是多一个孤儿文件(可被 scan_orphan_files.py 离线发现)。 + // ⚠️ 已知边界:正文 content 内嵌的 `` 不在此清理范围, + // 因为无法可靠区分「本系统上传」与「外链/人工插入」;如需彻底治理走孤儿扫描。 + const fileResult = this._removeNewsFile(attachmentUrl, 'deleteNews'); + ctx.logger.info('[deleteNews] id=%s 行已删,附件清理=%s url=%s', + id, fileResult, attachmentUrl || '(none)'); + return 1; } /**