| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397 |
- /**
- * 验证「多模态附件」这条链路(`/api/chat` 的 `pictures` / `files` 两个字段)。
- *
- * 历史:2026-09-18 时新接口只收 `{thread_id, question}`,多一个字段 422,
- * 附件只能**把 OSS 地址拼进 question 文本**(方案②)。
- * 2026-09-23 后端支持了 `pictures` / `files`(方案①),改为走请求体字段。
- *
- * **实测到的契约**(2026-09-23,打真实后端):
- * - 两者都是 **URL 字符串数组**;传对象数组(`[{url,name}]`)会被 `422 invalid_request` 拒掉
- * - `pictures` 里的图**由后端自己去取**(必须公网可达)——
- * 实测带上 `pictures:[图URL]` 问「这张图片里是什么」,模型确实描述了图的内容
- * - 空数组与不传都接受
- *
- * 不变量:
- * ① **没有附件时请求体逐字节与从前一致**(只有 thread_id + question)——
- * 既不多传无意义字段,也让「后端若回滚」只影响带附件的轮次
- * ② 有附件时:`question` 仍是**用户原文**(地址不拼进去),附件在 `pictures` / `files` 里
- * ③ 图片按 `isImage` 进 `pictures`,其余进 `files`;空 URL 被过滤掉
- *
- * 怎么跑(在项目根目录):
- * npx esbuild harness/tools/_entry-attachments.ts --bundle --format=esm \
- * --outfile=harness/tools/_attachments.mjs --alias:@=./src \
- * --define:import.meta.env='{}' --loader:.png=dataurl
- * # ⚠️ 必须带 --loader:.png=dataurl:协调器 import 了企业微信二维码
- * node harness/tools/verify-attachment-question.mjs
- */
- import { readFileSync, readdirSync } from 'node:fs';
- const {
- toChatAttachmentUrls,
- ApiChatCoordinator,
- useBusinessAssistantUpload,
- SUPPORTED_IMAGE_EXTENSIONS,
- SUPPORTED_ATTACHMENT_EXTENSIONS,
- SUPPORTED_IMAGE_LABEL,
- SUPPORTED_ATTACHMENT_LABEL,
- } = await import('./_attachments.mjs');
- let pass = 0;
- let fail = 0;
- const check = (name, cond, extra = '') => {
- if (cond) {
- pass++;
- console.log(' ok ' + name);
- } else {
- fail++;
- console.log(' FAIL ' + name + ' ' + extra);
- }
- };
- const IMG1 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/a.png';
- const IMG2 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/b.jpg';
- const PDF = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/c.pdf';
- const DOC = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/d.docx';
- const att = (url, isImage) => ({ id: url, name: url.split('/').pop(), size: 1, isImage, url });
- const tx = (attachments, files) => ({
- files: files ?? attachments.map((a) => a.url),
- file_pos: attachments.map(() => 0),
- attachments,
- });
- console.log('\n【1】按类型拆分(纯逻辑)');
- {
- const mixed = toChatAttachmentUrls(tx([att(IMG1, true), att(PDF, false), att(IMG2, true)]));
- check('图片进 pictures', JSON.stringify(mixed.pictures) === JSON.stringify([IMG1, IMG2]), JSON.stringify(mixed.pictures));
- check('非图片进 files', JSON.stringify(mixed.files) === JSON.stringify([PDF]), JSON.stringify(mixed.files));
- const onlyImages = toChatAttachmentUrls(tx([att(IMG1, true)]));
- check('只有图片时 files 为空数组', onlyImages.files.length === 0 && onlyImages.pictures.length === 1);
- const none = toChatAttachmentUrls(undefined);
- check('没有附件 → 两个空数组', none.pictures.length === 0 && none.files.length === 0);
- check('null 也安全', JSON.stringify(toChatAttachmentUrls(null)) === JSON.stringify({ pictures: [], files: [] }));
- check('空 attachments 数组 → 两个空数组', JSON.stringify(toChatAttachmentUrls(tx([]))) === JSON.stringify({ pictures: [], files: [] }));
- const dirty = toChatAttachmentUrls(tx([att(' ' + IMG1 + ' ', true), att('', false)]));
- check('URL 前后空格去掉、空 URL 过滤掉', JSON.stringify(dirty.pictures) === JSON.stringify([IMG1]) && dirty.files.length === 0, JSON.stringify(dirty));
- const noAttachments = toChatAttachmentUrls({ files: [PDF, DOC], file_pos: [0, 0] });
- check('只有 files(没 attachments)→ 全落 files,地址不丢', JSON.stringify(noAttachments.files) === JSON.stringify([PDF, DOC]), JSON.stringify(noAttachments));
- }
- /* ------------------------------------------------------------------ *
- * 协调器:真正发出的请求体
- * ------------------------------------------------------------------ */
- const sse = (events) =>
- events
- .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`)
- .join('');
- let captured = [];
- const stubFetch = () => {
- globalThis.fetch = async (url, init = {}) => {
- captured.push(init.body ? JSON.parse(String(init.body)) : null);
- return new Response(
- new ReadableStream({
- start(controller) {
- controller.enqueue(new TextEncoder().encode(sse([['done', { status: 'completed' }]])));
- controller.close();
- },
- }),
- { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
- );
- };
- };
- const runTurn = async (question, attachments) => {
- captured = [];
- stubFetch();
- const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
- await coordinator.generateAnswer(question, attachments);
- await new Promise((r) => setTimeout(r, 60));
- return captured[0];
- };
- console.log('\n【2】请求体:没有附件时与从前逐字节一致');
- {
- const body = await runTurn('帮我看看这份材料符合条件吗');
- check(
- '只有 thread_id + question',
- JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['question', 'thread_id']),
- JSON.stringify(body)
- );
- check('不该出现空数组字段', !('pictures' in body) && !('files' in body), JSON.stringify(body));
- const empty = await runTurn('没有附件', { pictures: [], files: [] });
- check('显式传空数组也不发字段', !('pictures' in empty) && !('files' in empty), JSON.stringify(empty));
- }
- console.log('\n【3】请求体:带附件时');
- {
- const Q = '帮我看看这份材料符合条件吗';
- const body = await runTurn(Q, { pictures: [IMG1], files: [PDF] });
- check('**question 是用户原文**(地址不拼进去)', body.question === Q, JSON.stringify(body.question));
- check('pictures 是 URL 字符串数组', JSON.stringify(body.pictures) === JSON.stringify([IMG1]), JSON.stringify(body.pictures));
- check('files 是 URL 字符串数组', JSON.stringify(body.files) === JSON.stringify([PDF]), JSON.stringify(body.files));
- check('没有多余字段', JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['files', 'pictures', 'question', 'thread_id']), JSON.stringify(Object.keys(body)));
- check(
- 'pictures 里**不带**对象结构(后端只认字符串数组,传对象 422)',
- body.pictures.every((item) => typeof item === 'string'),
- JSON.stringify(body.pictures)
- );
- const multi = await runTurn(Q, { pictures: [IMG1, IMG2], files: [PDF, DOC] });
- check('支持多个图片与文件', multi.pictures.length === 2 && multi.files.length === 2, JSON.stringify(multi));
- const dirty = await runTurn(Q, { pictures: ['', ' '], files: [PDF] });
- check('空 URL 被清掉(不会发出 [""])', !('pictures' in dirty) && JSON.stringify(dirty.files) === JSON.stringify([PDF]), JSON.stringify(dirty));
- }
- console.log('\n【4】旧方案已停用(源码级)');
- {
- const files = [];
- const walk = (dir) => {
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
- const full = `${dir}/${entry.name}`;
- if (entry.isDirectory()) walk(full);
- else if (/\.(ts|vue)$/.test(entry.name)) files.push(full);
- }
- };
- walk('src');
- const callers = files.filter((file) => {
- const text = readFileSync(file, 'utf8');
- // 跳过它自己的定义(已在 shared.ts 里注释掉)
- return text.split('\n').some((line) => !line.trim().startsWith('//') && line.includes('buildQuestionWithAttachments'));
- });
- check('没有活跃调用点(地址不再拼进 question)', callers.length === 0, callers.join(', '));
- }
- console.log('\n【5】支持格式的白名单(与产品提示同一口径)');
- {
- check(
- '图片:JPEG / PNG / GIF / WebP',
- JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS) === JSON.stringify(['jpg', 'jpeg', 'png', 'gif', 'webp']),
- JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS)
- );
- check(
- '文件:Excel / DOCX / PDF',
- JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS) === JSON.stringify(['xls', 'xlsx', 'docx', 'pdf']),
- JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS)
- );
- // 用户 2026-09-23 指出的缺口:提示里写了 GIF,白名单里原来没有
- check('**GIF 确实在白名单里**(提示说支持就得真支持)', SUPPORTED_IMAGE_EXTENSIONS.includes('gif'));
- check('**Excel 确实在白名单里**', SUPPORTED_ATTACHMENT_EXTENSIONS.includes('xlsx') && SUPPORTED_ATTACHMENT_EXTENSIONS.includes('xls'));
- // 提示文案与白名单必须描述同一批格式:文案里出现的每个格式名都要能在白名单里找到
- const labelCovers = (label, list, aliases = {}) => {
- const named = String(label).split(/[、,,/]/).map((x) => x.trim()).filter(Boolean);
- return named.every((name) => {
- const key = aliases[name] || name.toLowerCase();
- // 文案里可能写成「文字PDF」这类带修饰的写法 → 用包含匹配(PDF 在白名单里即可)
- return list.includes(key) || list.some((ext) => name.toLowerCase().includes(ext));
- });
- };
- check(
- '提示文案里的图片格式都在白名单里',
- labelCovers(SUPPORTED_IMAGE_LABEL, SUPPORTED_IMAGE_EXTENSIONS),
- `${SUPPORTED_IMAGE_LABEL} ↔ ${JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS)}`
- );
- check(
- '提示文案里的文件格式都在白名单里',
- labelCovers(SUPPORTED_ATTACHMENT_LABEL, SUPPORTED_ATTACHMENT_EXTENSIONS, { Excel: 'xlsx', PDF: 'pdf', DOCX: 'docx' }),
- `${SUPPORTED_ATTACHMENT_LABEL} ↔ ${JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS)}`
- );
- // ⚠️ 另一处口径:PC / Mobile 的 <input accept="…"> 若与白名单不一致,就会出现
- // 「文件选择框里选不了某些格式」或「选得进但被校验拒掉」(用户这次报的就是这类)
- for (const file of ['src/components/BusinessAssistantPC.vue', 'src/components/BusinessAssistantMobile.vue']) {
- const src = readFileSync(file, 'utf8');
- const imageAccept = (src.match(/accept="(image\/[^"]+)"/) || [])[1] || '';
- const fileAccept = (src.match(/accept="((?:\.[a-z0-9]+)(?:,\.[a-z0-9]+)*)"/) || [])[1] || '';
- // ⚠️ jpg 与 jpeg 是**同一个 MIME**(image/jpeg)—— 两边都要归一后再比,
- // 否则「accept 写 image/jpeg、白名单同时列了 jpg 与 jpeg」会被误报成不一致
- const norm = (exts) => [...new Set(exts.map((x) => (x === 'jpeg' ? 'jpg' : x)))].sort();
- const acceptExts = imageAccept
- .split(',')
- .map((x) => x.trim().replace(/^image\//, ''))
- .filter(Boolean);
- check(
- `${file.split('/').pop()} 的图片 accept 与白名单一致`,
- JSON.stringify(norm(acceptExts)) === JSON.stringify(norm(SUPPORTED_IMAGE_EXTENSIONS)),
- `${imageAccept} ↔ ${JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS)}`
- );
- check(
- `${file.split('/').pop()} 的文件 accept 与白名单一致`,
- JSON.stringify(fileAccept.split(',').map((x) => x.trim().replace(/^\./, '')).sort()) ===
- JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS.slice().sort()),
- `${fileAccept} ↔ ${JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS)}`
- );
- }
- }
- console.log('\n【6】不支持的格式:toast 提示 + **不放进输入框**(调真实上传模块)');
- {
- // node 里没有这几样,补上桩(只在「支持的格式」那条路径会用到)
- if (!globalThis.URL.createObjectURL) {
- globalThis.URL.createObjectURL = () => 'blob:stub';
- globalThis.URL.revokeObjectURL = () => {};
- }
- const stubFetchOk = () =>
- new Response(JSON.stringify({ code: 200, content: '/static/photo/1889-c_question-x.png' }), { status: 200 });
- const restoreFetch = globalThis.fetch;
- const toasts = [];
- const { uploadedFiles, handleFiles } = useBusinessAssistantUpload({ notify: (m) => toasts.push(m) });
- const f = (name, type = '') => new File(['x'], name, { type });
- globalThis.fetch = async () => stubFetchOk();
- // 6.1 全是不支持的格式 → 每个都提示一次,且一个都不加入
- handleFiles([f('a.svg', 'image/svg+xml'), f('b.zip'), f('c.doc'), f('d.txt')], false);
- await new Promise((r) => setTimeout(r, 20));
- check('**四个不支持的文件各弹一次提示**', toasts.length === 4, JSON.stringify(toasts));
- check('提示里带文件名与格式要求', toasts[0].includes('a.svg') && toasts[0].includes('Excel'), toasts[0]);
- check('**一个都没放进输入框**', uploadedFiles.value.length === 0, JSON.stringify(uploadedFiles.value.map((x) => x.name)));
- // 6.2 图片通道同样校验 —— **MIME 是 image/* 但扩展名不在白名单的(SVG/BMP/HEIC)也必须拦**
- // (原来这里只看 `type.startsWith('image/')`,SVG 会被当图片收下 —— 已修)
- toasts.length = 0;
- handleFiles([f('e.svg', 'image/svg+xml'), f('f.bmp', 'image/bmp'), f('g.heic', 'image/heic')], true);
- await new Promise((r) => setTimeout(r, 20));
- check('伪图片(image/* 但扩展名不在白名单)**也要拦**', toasts.length === 3, JSON.stringify(toasts));
- check('图片通道的提示给的是图片格式', toasts[0]?.includes('JPEG'), JSON.stringify(toasts[0]));
- check('伪图片没放进去', uploadedFiles.value.length === 0, JSON.stringify(uploadedFiles.value.map((x) => x.name)));
- // 6.3 支持的格式照旧放进去(证明上面的拦截没误伤)—— 注意图片走图片通道、文件走文件通道
- toasts.length = 0;
- handleFiles([f('ok.xlsx')], false);
- handleFiles([f('ok2.png', 'image/png'), f('ok3.gif', 'image/gif'), f('ok4.jpeg', 'image/jpeg')], true);
- await new Promise((r) => setTimeout(r, 30));
- check('支持的格式**没有**被拦', toasts.length === 0, JSON.stringify(toasts));
- check('支持的格式进了输入框(xlsx + png + gif + jpeg)', uploadedFiles.value.length === 4, JSON.stringify(uploadedFiles.value.map((x) => x.name)));
- // 6.4 超过大小限制也走同一条提示(此前同样是 window.alert)
- toasts.length = 0;
- const tooBig = f('big.pdf');
- Object.defineProperty(tooBig, 'size', { value: 51 * 1024 * 1024 }); // 只改 size,不真分配 51MB
- handleFiles([tooBig], false);
- await new Promise((r) => setTimeout(r, 20));
- check('超限时提示(且不是 window.alert)', toasts.length === 1 && toasts[0].includes('大小限制'), JSON.stringify(toasts));
- check('超限的文件没进输入框', uploadedFiles.value.length === 4, String(uploadedFiles.value.length));
- globalThis.fetch = restoreFetch;
- }
- console.log('\n【7】消息附件:`url`(能显示)与 `fileUrl`(发给后端)不能混 —— 回归:气泡里的图不显示');
- {
- // 用户 2026-09-23 报「提交之后看不到图片了」:
- // 换 DMS 后 `fileUrl` 从 OSS 绝对地址变成了 `/static/photo/…` 这种**相对路径**,
- // 而 `consumeSuccessfulFileTransmission` 当时仍然只填 `url: file.fileUrl` ——
- // 于是气泡里的 `<img src="/static/photo/…">` 经 dev server 拿到的是 index.html(200 但非图片),
- // 图**解不出来**。实测:相对路径 → text/html;`/dms` + 相对路径 → image/png。
- const stubFetchOk = () =>
- new Response(JSON.stringify({ code: 200, content: '/static/photo/1889-c_question-x.png' }), { status: 200 });
- const restoreFetch = globalThis.fetch;
- globalThis.fetch = async () => stubFetchOk();
- const RELATIVE = '/static/photo/1889-c_question-x.png';
- const DISPLAY = '/dms' + RELATIVE;
- // 7.1 配好 DMS 前缀(开发环境的取值,见 .env.development 的 VITE_DMS_API)
- globalThis.VITE_DMS_API = '/dms';
- {
- const { uploadedFiles, handleFiles, consumeSuccessfulFileTransmission } = useBusinessAssistantUpload({
- notify: () => {},
- });
- handleFiles([new File(['x'], 'ok.png', { type: 'image/png' })], true);
- await new Promise((r) => setTimeout(r, 30));
- check('图片上传成功(DMS 前缀配好时)', uploadedFiles.value[0]?.status === 'success',
- JSON.stringify(uploadedFiles.value.map((f) => f.status)));
- const transmission = consumeSuccessfulFileTransmission(0);
- const attachment = (transmission.attachments || [])[0] || {};
- check('attachment.fileUrl = 相对路径(发给后端用)', attachment.fileUrl === RELATIVE, String(attachment.fileUrl));
- check('**attachment.url = 可显示地址(拼了代理前缀)**', attachment.url === DISPLAY, String(attachment.url));
- check('**两个字段不相等**(曾经的 bug 就是 url 直接用了相对路径)',
- attachment.url !== attachment.fileUrl, `${attachment.url} vs ${attachment.fileUrl}`);
- // 后端拿到的必须仍是相对路径 —— 换 DMS 后这是请求体契约
- const payload = toChatAttachmentUrls(transmission);
- check('请求体 pictures 里是相对路径(不是展示地址)',
- payload.pictures.length === 1 && payload.pictures[0] === RELATIVE, JSON.stringify(payload));
- check('展示地址**没有**混进请求体', !JSON.stringify(payload).includes('/dms'), JSON.stringify(payload));
- check('非图片附件不占 pictures', payload.files.length === 0, JSON.stringify(payload));
- }
- // 7.2 非图片文件:气泡里是个「文件条」(只显示名字/大小),不需要展示地址
- {
- const { uploadedFiles, handleFiles, consumeSuccessfulFileTransmission } = useBusinessAssistantUpload({
- notify: () => {},
- });
- globalThis.fetch = async () =>
- new Response(JSON.stringify({ code: 200, content: '/static/file/1889-c_question-y.xlsx' }), { status: 200 });
- handleFiles([new File(['x'], 'ok.xlsx')], false);
- await new Promise((r) => setTimeout(r, 30));
- const transmission = consumeSuccessfulFileTransmission(0);
- const attachment = (transmission.attachments || [])[0] || {};
- check('文件附件的 fileUrl 是相对路径', attachment.fileUrl === '/static/file/1889-c_question-y.xlsx',
- String(attachment.fileUrl));
- check('文件附件没有可显示地址(气泡走文件条,不需要)', attachment.url === undefined, String(attachment.url));
- check('文件附件仍进请求体 files', toChatAttachmentUrls(transmission).files.length === 1,
- JSON.stringify(toChatAttachmentUrls(transmission)));
- }
- // 7.3 没配 DMS 前缀时(生产环境当前就是没配,见 .env.production)——
- // 宁可**不给展示地址**,也不能塞一个取不到的地址让气泡显示成坏图
- {
- globalThis.VITE_DMS_API = '';
- const { uploadedFiles, handleFiles, consumeSuccessfulFileTransmission } = useBusinessAssistantUpload({
- notify: () => {},
- });
- globalThis.fetch = async () => stubFetchOk();
- handleFiles([new File(['x'], 'ok.png', { type: 'image/png' })], true);
- await new Promise((r) => setTimeout(r, 30));
- // 上传本身会失败(未配置 DMS 接口地址)→ 附件不会进消息,这是「拦在源头」
- check('未配 DMS 前缀时上传直接失败(不会产生一个坏地址的附件)',
- uploadedFiles.value.length === 0 || uploadedFiles.value[0]?.status === 'error',
- JSON.stringify(uploadedFiles.value.map((f) => f.status)));
- const transmission = consumeSuccessfulFileTransmission(0);
- check('失败的上传不会混进消息附件', (transmission.attachments || []).length === 0,
- JSON.stringify(transmission.attachments));
- }
- globalThis.VITE_DMS_API = '';
- globalThis.fetch = restoreFetch;
- }
- console.log('\n【8】附件路径随请求一起落 DMS(源码级 —— hook 里的事,node 跑不起来)');
- {
- // 落库那一端的读写行为由 verify-dms-chat-storage.mjs 的【8】打真实 DMS 验(13 项);
- // 这里只钉「hook 有没有把路径递下去」这一段接线(用户 2026-09-23 要求:
- // 图片进 c_image、文件进 c_file)。
- const hook = readFileSync('src/components/business-assistant/useBusinessAssistantChat.ts', 'utf8');
- const dms = readFileSync('src/network/api/dms/chat-sessions-dms.ts', 'utf8');
- check('sendMessage 把附件路径一并交给 DMS 写入',
- /images:\s*attachmentsForBackend\.pictures/.test(hook) && /files:\s*attachmentsForBackend\.files/.test(hook));
- check('用的就是发给后端的那两份(同一口径,不是另算一套地址)',
- hook.split('images: attachmentsForBackend.pictures').length - 1 === 1);
- check('upsertDmsRecord 收 images / files 两个参数',
- /images\?: string\[\]/.test(dms) && /files\?: string\[\]/.test(dms));
- check('写入落到 c_image / c_file 两列',
- /content\.c_image = images/.test(dms) && /content\.c_file = files/.test(dms));
- check('没有附件时不写这两列(与请求体同口径)',
- /if \(images\) content\.c_image = images;/.test(dms) && /if \(files\) content\.c_file = files;/.test(dms));
- // ⚠️ updateContent 遇到「模型里不存在的列」会静默丢弃**整个** patch(DMS_API.md 的陷阱):
- // 附件列必须只在真有值时才进 patch,否则补 answer 这条主链路会被连累
- check('更新路径里附件列只在「有值且该列还空着」时才进 patch',
- /if \(images && !existing\.c_image\) patch\.c_image = images;/.test(dms) &&
- /if \(files && !existing\.c_file\) patch\.c_file = files;/.test(dms));
- }
- console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
- process.exit(fail ? 1 : 0);
|