feat : 一体机定位直到成功,其他环境循环2分钟定位, 上传加入文件校验
This commit is contained in:
12
App.vue
12
App.vue
@@ -24,6 +24,7 @@ onLaunch((options) => {
|
||||
getUserInfo();
|
||||
useUserStore().changMiniProgramAppStatus(false);
|
||||
useUserStore().changMachineEnv(false);
|
||||
useLocationStore().getLocationLoop()//循环获取定位
|
||||
return;
|
||||
}
|
||||
if (isY9MachineType()) {
|
||||
@@ -32,7 +33,14 @@ onLaunch((options) => {
|
||||
useUserStore().logOutApp();
|
||||
useUserStore().changMiniProgramAppStatus(true);
|
||||
useUserStore().changMachineEnv(true);
|
||||
useLocationStore().getLocation();
|
||||
useLocationStore().getLocation()
|
||||
.then(({longitude,latitude})=>{
|
||||
console.log(`✅一体机获取定位成功:lng:${longitude},lat${latitude}`)
|
||||
})
|
||||
.catch(err=>{
|
||||
console.log('❌一体机获取定位失败,30s后尝试重新获取')
|
||||
useLocationStore().getLocation()
|
||||
})
|
||||
uQRListen = new IncreaseRevie();
|
||||
inactivityManager = new GlobalInactivityManager(handleInactivity, 60 * 1000);
|
||||
inactivityManager.start();
|
||||
@@ -40,6 +48,7 @@ onLaunch((options) => {
|
||||
}
|
||||
// 正式上线去除此方法
|
||||
console.warn('浏览器环境');
|
||||
useLocationStore().getLocationLoop()//循环获取定位
|
||||
useUserStore().changMiniProgramAppStatus(true);
|
||||
useUserStore().changMachineEnv(false);
|
||||
useUserStore().initSeesionId(); //更新
|
||||
@@ -57,6 +66,7 @@ onLaunch((options) => {
|
||||
|
||||
onMounted(() => {});
|
||||
|
||||
|
||||
onShow(() => {
|
||||
console.log('App Show');
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ const { userInfo } = storeToRefs(useUserStore());
|
||||
const { getUserResume } = useUserStore();
|
||||
const { dictLabel, oneDictData } = useDictStore();
|
||||
const openSelectPopup = inject('openSelectPopup');
|
||||
import {FileValidator} from "@/utils/fileValidator" //文件校验
|
||||
|
||||
const percent = ref('0%');
|
||||
const state = reactive({
|
||||
@@ -278,8 +279,16 @@ function selectAvatar() {
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
count: 1,
|
||||
success: ({ tempFilePaths, tempFiles }) => {
|
||||
$api.uploadFile(tempFilePaths[0], true)
|
||||
success: async(res) => {
|
||||
const tempFilePaths = res.tempFilePaths;
|
||||
const file = res.tempFiles[0];
|
||||
|
||||
const imageValidator = new FileValidator()
|
||||
|
||||
try {
|
||||
await imageValidator.validate(file)
|
||||
|
||||
$api.uploadFile(tempFilePaths[0], true)
|
||||
.then((res) => {
|
||||
res = JSON.parse(res);
|
||||
if (res.msg) fromValue.avatar = res.msg;
|
||||
@@ -287,6 +296,9 @@ function selectAvatar() {
|
||||
.catch((err) => {
|
||||
$api.msg('上传失败');
|
||||
});
|
||||
} catch (error) {
|
||||
$api.msg(error);
|
||||
}
|
||||
},
|
||||
fail: (error) => {},
|
||||
});
|
||||
|
||||
@@ -281,6 +281,8 @@ const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGrou
|
||||
import successIcon from '@/static/icon/success.png';
|
||||
import useUserStore from '@/stores/useUserStore';
|
||||
const { isMachineEnv } = storeToRefs(useUserStore());
|
||||
|
||||
import {FileValidator} from "@/utils/fileValidator" //文件校验
|
||||
// hook
|
||||
// 语音识别
|
||||
const {
|
||||
@@ -536,22 +538,29 @@ function uploadCamera(type = 'camera') {
|
||||
count: 1, //默认9
|
||||
sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
|
||||
sourceType: [type], //从相册选择
|
||||
success: function (res) {
|
||||
success: async (res)=> {
|
||||
const tempFilePaths = res.tempFilePaths;
|
||||
const file = res.tempFiles[0];
|
||||
// 继续上传
|
||||
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
||||
resData = JSON.parse(resData);
|
||||
console.log(file.type,'++')
|
||||
if (isImage(file.type)) {
|
||||
filesList.value.push({
|
||||
url: resData.msg,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
});
|
||||
textInput.value = state.uploadFileTips;
|
||||
}
|
||||
});
|
||||
|
||||
const imageValidator = new FileValidator()
|
||||
try {
|
||||
await imageValidator.validate(file)
|
||||
|
||||
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
||||
resData = JSON.parse(resData);
|
||||
console.log(file.type,'++')
|
||||
if (isImage(file.type)) {
|
||||
filesList.value.push({
|
||||
url: resData.msg,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
});
|
||||
textInput.value = state.uploadFileTips;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
$api.msg(error)
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -560,25 +569,30 @@ function getUploadFile(type = 'camera') {
|
||||
if (VerifyNumberFiles()) return;
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
success: async(res) => {
|
||||
const tempFilePaths = res.tempFilePaths;
|
||||
const file = res.tempFiles[0];
|
||||
const allowedTypes = config.allowedFileTypes || [];
|
||||
const size = $api.formatFileSize(file.size);
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return $api.msg('仅支持 txt md word pdf ppt csv excel 格式类型');
|
||||
}
|
||||
// 继续上传
|
||||
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
||||
resData = JSON.parse(resData);
|
||||
filesList.value.push({
|
||||
url: resData.msg,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
size: size,
|
||||
|
||||
const imageValidator = new FileValidator({allowedExtensions:config.allowedFileTypes})
|
||||
|
||||
try{
|
||||
await imageValidator.validate(file)
|
||||
|
||||
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
|
||||
resData = JSON.parse(resData);
|
||||
filesList.value.push({
|
||||
url: resData.msg,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
size: size,
|
||||
});
|
||||
textInput.value = state.uploadFileTips;
|
||||
});
|
||||
textInput.value = state.uploadFileTips;
|
||||
});
|
||||
}catch(error){
|
||||
$api.msg(error)
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -470,7 +470,6 @@ const { columnCount, columnSpace } = useColumnCount(() => {
|
||||
getJobRecommend('refresh');
|
||||
nextTick(() => {
|
||||
waterfallsFlowRef.value?.refresh?.();
|
||||
useLocationStore().getLocation();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -129,7 +129,6 @@ const { columnCount, columnSpace } = useColumnCount(() => {
|
||||
pageSize.value = 10 * (columnCount.value - 1);
|
||||
nextTick(() => {
|
||||
waterfallsFlowRef.value?.refresh?.();
|
||||
useLocationStore().getLocation();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -151,7 +151,6 @@ const { columnCount, columnSpace } = useColumnCount(() => {
|
||||
pageSize.value = 10 * (columnCount.value - 1);
|
||||
nextTick(() => {
|
||||
waterfallsFlowRef.value?.refresh?.();
|
||||
useLocationStore().getLocation();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,13 +11,15 @@ import config from '../config';
|
||||
|
||||
const defalutLongLat = {
|
||||
longitude: 120.382665,
|
||||
latitude: 36.066938
|
||||
latitude: 36.066938,
|
||||
}
|
||||
|
||||
const useLocationStore = defineStore("location", () => {
|
||||
// 定义状态
|
||||
const longitudeVal = ref(null) // 经度
|
||||
const latitudeVal = ref(null) //纬度
|
||||
const timer = ref(null)
|
||||
const count = ref(0)
|
||||
|
||||
function getLocation() { // 获取经纬度两个平台
|
||||
return new Promise((resole, reject) => {
|
||||
@@ -66,6 +68,21 @@ const useLocationStore = defineStore("location", () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
function getLocationLoop(gap = 1000 * 60 * 2) {
|
||||
console.log(`🔄开始循环获取定位,间隔:${Math.floor(gap/1000)}秒`)
|
||||
const run = () => {
|
||||
count.value++
|
||||
console.log(`📍第${count.value}次获取定位`)
|
||||
getLocation()
|
||||
}
|
||||
run()
|
||||
timer.value = setInterval(run,gap);
|
||||
}
|
||||
|
||||
function clearGetLocationLoop(params) {
|
||||
clearInterval(timer.value)
|
||||
timer.value = null
|
||||
}
|
||||
|
||||
function longitude() {
|
||||
return longitudeVal.value
|
||||
@@ -78,9 +95,10 @@ const useLocationStore = defineStore("location", () => {
|
||||
// 导入
|
||||
return {
|
||||
getLocation,
|
||||
getLocationLoop,
|
||||
clearGetLocationLoop,
|
||||
longitudeVal,
|
||||
latitudeVal
|
||||
|
||||
latitudeVal,
|
||||
}
|
||||
}, {
|
||||
unistorage: true,
|
||||
|
||||
@@ -7,248 +7,277 @@
|
||||
// 1. 预定义:已知文件类型的魔数 (Signature Database)
|
||||
// ==========================================
|
||||
const KNOWN_SIGNATURES = {
|
||||
// === 图片 ===
|
||||
png: '89504E470D0A1A0A',
|
||||
jpg: 'FFD8FF',
|
||||
jpeg: 'FFD8FF',
|
||||
gif: '47494638',
|
||||
webp: '52494646', // RIFF Header
|
||||
// === 图片 ===
|
||||
png: '89504E470D0A1A0A',
|
||||
jpg: 'FFD8FF',
|
||||
jpeg: 'FFD8FF',
|
||||
gif: '47494638',
|
||||
webp: '52494646', // RIFF Header
|
||||
|
||||
// === 文档 (Office 新版 - ZIP 格式) ===
|
||||
docx: '504B0304',
|
||||
xlsx: '504B0304',
|
||||
pptx: '504B0304',
|
||||
// === 文档 (Office 新版 - ZIP 格式) ===
|
||||
docx: '504B0304',
|
||||
xlsx: '504B0304',
|
||||
pptx: '504B0304',
|
||||
|
||||
// === 文档 (Office 旧版 - OLECF 格式) ===
|
||||
doc: 'D0CF11E0',
|
||||
xls: 'D0CF11E0',
|
||||
ppt: 'D0CF11E0',
|
||||
// === 文档 (Office 旧版 - OLECF 格式) ===
|
||||
doc: 'D0CF11E0',
|
||||
xls: 'D0CF11E0',
|
||||
ppt: 'D0CF11E0',
|
||||
|
||||
// === 其他 ===
|
||||
pdf: '25504446',
|
||||
// === 其他 ===
|
||||
pdf: '25504446',
|
||||
|
||||
// === 纯文本 (无固定魔数,需特殊算法检测) ===
|
||||
txt: 'TYPE_TEXT',
|
||||
csv: 'TYPE_TEXT',
|
||||
md: 'TYPE_TEXT',
|
||||
json: 'TYPE_TEXT',
|
||||
// === 纯文本 (无固定魔数,需特殊算法检测) ===
|
||||
txt: 'TYPE_TEXT',
|
||||
csv: 'TYPE_TEXT',
|
||||
md: 'TYPE_TEXT',
|
||||
json: 'TYPE_TEXT',
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// 2. 核心类定义
|
||||
// ==========================================
|
||||
export class FileValidator {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} options 配置项
|
||||
* @param {number} [options.maxSizeMB=10] 最大文件大小 (MB)
|
||||
* @param {string[]} [options.allowedExtensions] 允许的扩展名列表 (如 ['jpg', 'png']),默认允许全部已知类型
|
||||
*/
|
||||
version = '1.0.0';
|
||||
constructor(options = {}) {
|
||||
// 配置大小 (默认 10MB)
|
||||
this.maxSizeMB = options.maxSizeMB || 10;
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} options 配置项
|
||||
* @param {number} [options.maxSizeMB=10] 最大文件大小 (MB)
|
||||
* @param {string[]} [options.allowedExtensions = ['png','jpg','jpeg','gif','webp','svg','bmp','ico','pdf','doc','docx','xls','xlsx','csv','ppt','pptx','txt','md']] 允许的扩展名列表 (如 ['jpg', 'png']),默认允许全部已知类型
|
||||
*/
|
||||
version = '1.0.0';
|
||||
constructor(options = {}) {
|
||||
// 配置大小 (默认 10MB)
|
||||
this.maxSizeMB = options.maxSizeMB || 10;
|
||||
|
||||
// 配置允许的类型
|
||||
// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES
|
||||
if (options.allowedExtensions && Array.isArray(options.allowedExtensions)) {
|
||||
this.allowedConfig = {};
|
||||
options.allowedExtensions.forEach((ext) => {
|
||||
const key = ext.toLowerCase();
|
||||
if (KNOWN_SIGNATURES[key]) {
|
||||
this.allowedConfig[key] = KNOWN_SIGNATURES[key];
|
||||
} else {
|
||||
console.warn(`[FileValidator] 未知的文件类型: .${key},已忽略`);
|
||||
}
|
||||
});
|
||||
// 扩展名到 MIME 的映射(用于反向查找)
|
||||
this.extToMime = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
json: 'application/json',
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
};
|
||||
|
||||
// 如果传入的是 MIME 类型,转换为扩展名
|
||||
let allowedExtensions = options.allowedExtensions || Object.keys(KNOWN_SIGNATURES);
|
||||
|
||||
// 配置允许的类型
|
||||
this.allowedConfig = {};
|
||||
allowedExtensions.forEach((extOrMime) => {
|
||||
const key = extOrMime.toLowerCase();
|
||||
|
||||
// 如果是 MIME 类型,尝试转换为扩展名
|
||||
let ext = key;
|
||||
if (key.includes('/')) {
|
||||
// 查找对应的扩展名
|
||||
for (const [e, mime] of Object.entries(this.extToMime)) {
|
||||
if (mime === key) {
|
||||
ext = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (KNOWN_SIGNATURES[ext]) {
|
||||
this.allowedConfig[ext] = KNOWN_SIGNATURES[ext];
|
||||
} else {
|
||||
console.warn(`[FileValidator] 未知的文件类型: ${key},已忽略`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 改进版:检查是否为有效的 UTF-8 文本
|
||||
*/
|
||||
_isValidUTF8(buffer) {
|
||||
try {
|
||||
// fatal: true 会在遇到无效编码时抛出错误,而不是用 替换
|
||||
const decoder = new TextDecoder('utf-8', {
|
||||
fatal: true,
|
||||
});
|
||||
decoder.decode(buffer);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助:ArrayBuffer 转 Hex 字符串
|
||||
*/
|
||||
_bufferToHex(buffer) {
|
||||
return Array.prototype.map
|
||||
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 【新增】统计 CSV 行数(严谨版:忽略引号内的换行符)
|
||||
* 性能:对于 10MB 文件,现代浏览器处理通常在 100ms 以内
|
||||
*/
|
||||
_countCSVRows(buffer) {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const text = decoder.decode(buffer);
|
||||
|
||||
let rowCount = 0;
|
||||
let inQuote = false;
|
||||
let len = text.length;
|
||||
|
||||
// 遍历每一个字符
|
||||
for (let i = 0; i < len; i++) {
|
||||
const char = text[i];
|
||||
|
||||
// 切换引号状态
|
||||
if (char === '"') {
|
||||
inQuote = !inQuote;
|
||||
}
|
||||
// 只有在非引号状态下的换行符,才算作一行结束
|
||||
else if (char === '\n' && !inQuote) {
|
||||
rowCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后一行没有换行符的情况(且文件不为空)
|
||||
if (len > 0 && text[len - 1] !== '\n') {
|
||||
rowCount++;
|
||||
}
|
||||
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 【核心】:校验纯文本内容
|
||||
* 1. 检查是否包含乱码 (非 UTF-8)
|
||||
* 2. 针对特定格式 (JSON) 进行语法解析
|
||||
*/
|
||||
_validateTextContent(buffer, extension) {
|
||||
// 1. 尝试解码为 UTF-8
|
||||
let contentStr = '';
|
||||
try {
|
||||
const decoder = new TextDecoder('utf-8', {
|
||||
fatal: true,
|
||||
});
|
||||
contentStr = decoder.decode(buffer);
|
||||
} catch (e) {
|
||||
// 如果解码失败,说明包含非文本的二进制数据
|
||||
console.warn('UTF-8 解码失败', e);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 检查是否存在过多的空字符 (二进制文件特征)
|
||||
// 某些二进制文件可能勉强通过 UTF-8 解码,但会包含大量 \0
|
||||
if (contentStr.includes('\u0000')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 针对特定后缀进行语法校验 (可选,更严格)
|
||||
if (extension === 'json') {
|
||||
try {
|
||||
JSON.parse(contentStr);
|
||||
} catch (e) {
|
||||
console.warn('无效的 JSON 格式');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是 CSV,可以简单检查行数(可选)
|
||||
// if (extension === 'csv') { ... }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行校验
|
||||
* @param {File} file 文件对象
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
validate(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('开始校验文件');
|
||||
// 1. 基础对象检查
|
||||
if (!file || !file.name) return reject('无效的文件对象');
|
||||
|
||||
// 2. 大小检查
|
||||
if (file.size > this.maxSizeMB * 1024 * 1024) {
|
||||
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
|
||||
}
|
||||
|
||||
// 3. 后缀名检查
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||
|
||||
// 检查是否在配置的白名单中
|
||||
const expectedMagic = this.allowedConfig[extension];
|
||||
if (!expectedMagic) {
|
||||
return reject(`不支持的文件格式: .${extension}`);
|
||||
}
|
||||
|
||||
// 4. 读取二进制头进行魔数校验
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const buffer = e.target.result;
|
||||
let isSafe = false;
|
||||
|
||||
// 分支处理:纯文本 vs 二进制
|
||||
if (expectedMagic === 'TYPE_TEXT') {
|
||||
if (this._validateTextContent(buffer, extension)) {
|
||||
isSafe = true;
|
||||
} else {
|
||||
// 细化报错信息
|
||||
if (extension === 'json') {
|
||||
return reject(`文件异常:不是有效的 JSON 文件`);
|
||||
}
|
||||
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
|
||||
}
|
||||
|
||||
// 【新增】专门针对 CSV 的行数检查
|
||||
if (extension === 'csv' && this.csvMaxRows > 0) {
|
||||
const rows = this._countCSVRows(buffer);
|
||||
// 注意:这里通常把表头也算作 1 行,如果不算表头可以将 limit + 1
|
||||
if (rows > this.csvMaxRows) {
|
||||
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.allowedConfig = {
|
||||
...KNOWN_SIGNATURES
|
||||
};
|
||||
}
|
||||
}
|
||||
// 获取文件头 Hex (读取足够长的字节以覆盖最长的魔数,PNG需8字节)
|
||||
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
|
||||
|
||||
/**
|
||||
* 改进版:检查是否为有效的 UTF-8 文本
|
||||
*/
|
||||
_isValidUTF8(buffer) {
|
||||
try {
|
||||
// fatal: true 会在遇到无效编码时抛出错误,而不是用 替换
|
||||
const decoder = new TextDecoder('utf-8', {
|
||||
fatal: true
|
||||
});
|
||||
decoder.decode(buffer);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助:ArrayBuffer 转 Hex 字符串
|
||||
*/
|
||||
_bufferToHex(buffer) {
|
||||
return Array.prototype.map
|
||||
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 【新增】统计 CSV 行数(严谨版:忽略引号内的换行符)
|
||||
* 性能:对于 10MB 文件,现代浏览器处理通常在 100ms 以内
|
||||
*/
|
||||
_countCSVRows(buffer) {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const text = decoder.decode(buffer);
|
||||
|
||||
let rowCount = 0;
|
||||
let inQuote = false;
|
||||
let len = text.length;
|
||||
|
||||
// 遍历每一个字符
|
||||
for (let i = 0; i < len; i++) {
|
||||
const char = text[i];
|
||||
|
||||
// 切换引号状态
|
||||
if (char === '"') {
|
||||
inQuote = !inQuote;
|
||||
}
|
||||
// 只有在非引号状态下的换行符,才算作一行结束
|
||||
else if (char === '\n' && !inQuote) {
|
||||
rowCount++;
|
||||
}
|
||||
// 使用 startsWith 匹配
|
||||
if (fileHeader.startsWith(expectedMagic)) {
|
||||
isSafe = true;
|
||||
} else {
|
||||
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后一行没有换行符的情况(且文件不为空)
|
||||
if (len > 0 && text[len - 1] !== '\n') {
|
||||
rowCount++;
|
||||
}
|
||||
if (isSafe) resolve(true);
|
||||
};
|
||||
|
||||
return rowCount;
|
||||
}
|
||||
reader.onerror = () => reject('文件读取失败,无法校验');
|
||||
|
||||
|
||||
/**
|
||||
* 【核心】:校验纯文本内容
|
||||
* 1. 检查是否包含乱码 (非 UTF-8)
|
||||
* 2. 针对特定格式 (JSON) 进行语法解析
|
||||
*/
|
||||
_validateTextContent(buffer, extension) {
|
||||
// 1. 尝试解码为 UTF-8
|
||||
let contentStr = '';
|
||||
try {
|
||||
const decoder = new TextDecoder('utf-8', {
|
||||
fatal: true
|
||||
});
|
||||
contentStr = decoder.decode(buffer);
|
||||
} catch (e) {
|
||||
// 如果解码失败,说明包含非文本的二进制数据
|
||||
console.warn('UTF-8 解码失败', e);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 检查是否存在过多的空字符 (二进制文件特征)
|
||||
// 某些二进制文件可能勉强通过 UTF-8 解码,但会包含大量 \0
|
||||
if (contentStr.includes('\u0000')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 针对特定后缀进行语法校验 (可选,更严格)
|
||||
if (extension === 'json') {
|
||||
try {
|
||||
JSON.parse(contentStr);
|
||||
} catch (e) {
|
||||
console.warn('无效的 JSON 格式');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是 CSV,可以简单检查行数(可选)
|
||||
// if (extension === 'csv') { ... }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行校验
|
||||
* @param {File} file 文件对象
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
validate(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 1. 基础对象检查
|
||||
if (!file || !file.name) return reject('无效的文件对象');
|
||||
|
||||
// 2. 大小检查
|
||||
if (file.size > this.maxSizeMB * 1024 * 1024) {
|
||||
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
|
||||
}
|
||||
|
||||
// 3. 后缀名检查
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||
|
||||
// 检查是否在配置的白名单中
|
||||
const expectedMagic = this.allowedConfig[extension];
|
||||
if (!expectedMagic) {
|
||||
return reject(`不支持的文件格式: .${extension}`);
|
||||
}
|
||||
|
||||
// 4. 读取二进制头进行魔数校验
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const buffer = e.target.result;
|
||||
let isSafe = false;
|
||||
|
||||
// 分支处理:纯文本 vs 二进制
|
||||
if (expectedMagic === 'TYPE_TEXT') {
|
||||
if (this._validateTextContent(buffer, extension)) {
|
||||
isSafe = true;
|
||||
} else {
|
||||
// 细化报错信息
|
||||
if (extension === 'json') {
|
||||
return reject(`文件异常:不是有效的 JSON 文件`);
|
||||
}
|
||||
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
|
||||
}
|
||||
|
||||
// 【新增】专门针对 CSV 的行数检查
|
||||
if (extension === 'csv' && this.csvMaxRows > 0) {
|
||||
const rows = this._countCSVRows(buffer);
|
||||
// 注意:这里通常把表头也算作 1 行,如果不算表头可以将 limit + 1
|
||||
if (rows > this.csvMaxRows) {
|
||||
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 获取文件头 Hex (读取足够长的字节以覆盖最长的魔数,PNG需8字节)
|
||||
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
|
||||
|
||||
// 使用 startsWith 匹配
|
||||
if (fileHeader.startsWith(expectedMagic)) {
|
||||
isSafe = true;
|
||||
} else {
|
||||
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSafe) resolve(true);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject('文件读取失败,无法校验');
|
||||
|
||||
// 读取前 1KB 进行判断
|
||||
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
|
||||
// JSON 必须读全量才能 parse,建议限制 JSON 文件大小
|
||||
reader.readAsArrayBuffer(file);
|
||||
} else {
|
||||
// 图片/普通文本 读取前 2KB 足够判断头部和编码特征
|
||||
reader.readAsArrayBuffer(file.slice(0, 2048));
|
||||
}
|
||||
});
|
||||
}
|
||||
// 读取前 1KB 进行判断
|
||||
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
|
||||
// JSON 必须读全量才能 parse,建议限制 JSON 文件大小
|
||||
reader.readAsArrayBuffer(file);
|
||||
} else {
|
||||
// 图片/普通文本 读取前 2KB 足够判断头部和编码特征
|
||||
reader.readAsArrayBuffer(file.slice(0, 2048));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 【demo】
|
||||
@@ -269,4 +298,4 @@ export class FileValidator {
|
||||
// .catch((err) => {
|
||||
// statusDiv.textContent = `检测失败: ${err}`;
|
||||
// statusDiv.style.color = 'red';
|
||||
// });
|
||||
// });
|
||||
|
||||
Reference in New Issue
Block a user