importOneFile($filePath, $channelId); $count++; // 导入成功后,把文件重命名为 .xlsx.done,避免下次重复导入 $newPath = $filePath . '.done'; if (!@rename($filePath, $newPath)) { Log::warning("FbFileImportService: 文件导入成功,但重命名失败:{$filePath} -> {$newPath}"); } else { Log::info("FbFileImportService: 文件导入并标记完成:{$newPath}(行数:{$rows})"); } } return $count; } /** * 导入单个 Excel 到 fb_leads */ public function importOneFile(string $filePath, int $channelId): int { if (!is_file($filePath)) { throw new BusinessException("Excel 文件不存在:{$filePath}"); } $spreadsheet = IOFactory::load($filePath); $sheet = $spreadsheet->getActiveSheet(); $rows = $sheet->toArray(null, true, true, true); if (empty($rows)) { throw new BusinessException('Excel 内容为空:' . $filePath); } // 表头 $headerRow = array_shift($rows); $mapper = new FbLeadHeaderMapper(); $res = $mapper->buildColumnFieldMap($headerRow); $colToField = $res['colToField']; $unknownCols = $res['unknownCols']; $insertData = []; $now = time(); $sourceFile = basename($filePath); foreach ($rows as $row) { $item = []; foreach ($colToField as $colIndex => $field) { $raw = $row[$colIndex] ?? null; switch ($field) { case 'is_organic': $item['is_organic'] = $this->normalizeIsOrganic($raw); break; case 'fb_created_time': $item['fb_created_time'] = $this->normalizeCreatedTime($raw); // 你方案B的datetime break; // ✅ 这些是数值ID字段:可能出现 ag:/as:/c:/f:/l: 前缀 case 'ad_id': case 'adset_id': case 'campaign_id': case 'form_id': $item[$field] = $this->normalizeNumericId($raw); break; // ✅ lead id 也经常是 l:xxxx case 'fb_lead_id': $item['fb_lead_id'] = $this->normalizeNumericId($raw); break; // ✅ 电话可能是 p:+233xxxx case 'phone': $item['phone'] = $this->normalizePhone($raw); break; // ✅ 姓名:去掉粘连的 URL,并截断到下游 inquiry_log.contacts 列长度(255) case 'full_name': $item['full_name'] = $this->normalizeFullName($raw); break; default: if (is_string($raw)) $raw = trim($raw); $item[$field] = $raw; break; } } // 未知列收集到 extra_data(宽松模式) $extra = []; foreach ($unknownCols as $colIndex => $rawHeader) { $val = $row[$colIndex] ?? null; if ($val === null || $val === '') continue; $extra[$rawHeader] = is_string($val) ? trim($val) : $val; } $item['extra_data'] = $extra ? json_encode($extra, JSON_UNESCAPED_UNICODE) : null; $item['source_file'] = $sourceFile; $item['channel'] = $channelId; $item['created_at'] = $now; $item['updated_at'] = $now; $item['sync_status'] = 0; $insertData[] = $item; } if ($insertData) { Db::table('fb_leads')->insert($insertData); } return count($insertData); } /** * 把 "ag:120..." / "l:831..." / "120..." 这种值清洗成纯数字(int 或 string数字) * - 如果你的 DB 字段是 BIGINT UNSIGNED,PHP int 在 64 位环境下是够用的 * - 解析失败返回 0(避免 insert 失败) */ protected function normalizeNumericId($raw) { if ($raw === null || $raw === '') return 0; // 数字直接返回 if (is_int($raw)) return $raw; if (is_float($raw)) return (int)$raw; $s = trim((string)$raw); if ($s === '') return 0; // 取出所有数字拼成一段(ag:120... -> 120..., l:xxx -> xxx) $digits = preg_replace('/\D+/', '', $s); if ($digits === '') return 0; // 这里返回字符串数字也可以,MySQL 会转;返回 int 在 64 位一般没问题 // 若你担心超出 int,可直接 return $digits; return $digits; } /** * 电话清洗:去掉 "p:" 前缀,保留 + 和数字 */ protected function normalizePhone($raw): string { if ($raw === null) return ''; $s = trim((string)$raw); if ($s === '') return ''; // 去掉类似 "p:" "tel:" 等前缀 $s = preg_replace('/^[a-zA-Z]+:\s*/', '', $s); // 保留 + 和数字 $s = preg_replace('/[^\d\+]+/', '', $s); return $s; } /** * 姓名清洗: * - 去掉粘连的 URL(如 "Saif Ullahhttps://..." → "Saif Ullah") * - 纯链接记录清洗后为空字符串 * - 截断到 255(下游 inquiry_log.contacts 列长度,避免同步入库时超长报错) */ protected function normalizeFullName($raw): string { if ($raw === null) return ''; $s = trim((string)$raw); if ($s === '') return ''; // 去掉 URL(连同前面可能的空白/不可见字符) $s = preg_replace('#\s*https?://\S+#i', '', $s); $s = trim($s); // 截断到下游 contacts 列长度兜底 return mb_substr($s, 0, 255); } /** * 规范化 is_organic 字段为 0/1 */ protected function normalizeIsOrganic($raw): int { if (is_bool($raw)) { return $raw ? 1 : 0; } $str = strtolower(trim((string)$raw)); if ($str === '') { return 0; } return in_array($str, ['1', 'true', 'yes', 'organic'], true) ? 1 : 0; } /** * 方案B:写 DATETIME,且永不为 null(因为 fb_created_time NOT NULL) * 兼容: * - Excel 数字日期 * - 10/13/25 * - 2025-12-10 04:25:09 * - 25年12月21日 / 2025年12月21日 * - 25年12月21日 13:05 / 25年12月21日13:05:09 * 为空/解析失败:用当前时间兜底 */ protected function normalizeCreatedTime($raw): string { $fallback = date('Y-m-d H:i:s'); if ($raw === null) return $fallback; // Excel 日期序列:数字 if (is_numeric($raw)) { $n = (float)$raw; $ts = (int)round(($n - 25569) * 86400); if ($ts > 0) return date('Y-m-d H:i:s', $ts); return $fallback; } $s = trim((string)$raw); if ($s === '') return $fallback; // 先尝试 strtotime(能覆盖很多格式:2025-12-10 04:25:09 / 2025/12/10 等) $ts = strtotime($s); if ($ts !== false) { return date('Y-m-d H:i:s', $ts); } // m/d/yy:10/13/25 if (preg_match('#^(\d{1,2})/(\d{1,2})/(\d{2})$#', $s, $m)) { $mm = (int)$m[1]; $dd = (int)$m[2]; $yy = (int)$m[3]; $year = 2000 + $yy; $ts2 = mktime(0, 0, 0, $mm, $dd, $year); return date('Y-m-d H:i:s', $ts2); } // ✅ 中文日期:25年12月21日 / 2025年12月21日(可带时间) // 支持: // - 25年12月21日 // - 2025年12月21日 // - 25年12月21日 13:05 // - 25年12月21日13:05:09 $cn = preg_replace('/\s+/', '', $s); // 去空格方便匹配 if (preg_match('/^(\d{2,4})年(\d{1,2})月(\d{1,2})日(?:(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/u', $cn, $m)) { $y = (int)$m[1]; $month = (int)$m[2]; $day = (int)$m[3]; // 2位年 -> 2000+(你这里是 25年 = 2025) if ($y < 100) $y += 2000; $hh = isset($m[4]) && $m[4] !== '' ? (int)$m[4] : 0; $ii = isset($m[5]) && $m[5] !== '' ? (int)$m[5] : 0; $ss = isset($m[6]) && $m[6] !== '' ? (int)$m[6] : 0; $ts3 = mktime($hh, $ii, $ss, $month, $day, $y); return date('Y-m-d H:i:s', $ts3); } // 解析失败兜底:当前时间 return $fallback; } }