From 6e37c6ec05a7b85bb2f103330cd19f26f71df626 Mon Sep 17 00:00:00 2001 From: Guoxianpeng <744964089@qq.com> Date: Wed, 22 Apr 2026 12:19:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E8=81=94=E7=B3=BB=E4=BA=BA=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/api/qywx.ts | 19 +- admin/src/api/qywx/index.js | 43 + admin/src/router/routes.ts | 28 + admin/src/views/fans/qywx.vue | 311 +++++- admin/src/views/qywx/index.vue | 901 ++++++++++++++++++ .../controller/BaseAdminController.php | 2 + .../controller/qywx/CustomerController.php | 877 ++++++++++++++++- server/app/adminapi/listener/OperationLog.php | 15 +- .../app/adminapi/lists/BaseAdminDataLists.php | 4 +- .../app/adminapi/lists/qywx/CustomerLists.php | 45 +- .../app/adminapi/logic/qywx/CustomerLogic.php | 290 ++---- .../validate/qywx/CustomerValidate.php | 34 - .../app/common/command/QywxSyncCustomer.php | 77 ++ .../middleware/LikeAdminAllowMiddleware.php | 16 +- .../app/common/model/QywxCustomerAddLog.php | 74 ++ .../common/model/QywxCustomerOperationLog.php | 59 ++ .../app/common/model/QywxExternalContact.php | 8 +- server/app/common/model/QywxStaffQrcode.php | 51 + server/app/common/model/QywxSyncSettings.php | 2 +- .../service/wechat/WechatWorkService.php | 500 +++++++++- server/config/console.php | 2 +- server/config/pay.php | 6 +- server/public/web.config | 24 + server/route/app.php | 49 +- sync_qywx.php | 250 +++++ 25 files changed, 3362 insertions(+), 325 deletions(-) create mode 100644 admin/src/api/qywx/index.js create mode 100644 admin/src/views/qywx/index.vue delete mode 100644 server/app/adminapi/validate/qywx/CustomerValidate.php create mode 100644 server/app/common/command/QywxSyncCustomer.php create mode 100644 server/app/common/model/QywxCustomerAddLog.php create mode 100644 server/app/common/model/QywxCustomerOperationLog.php create mode 100644 server/app/common/model/QywxStaffQrcode.php create mode 100644 server/public/web.config create mode 100644 sync_qywx.php diff --git a/admin/src/api/qywx.ts b/admin/src/api/qywx.ts index 55d6f4b7..c7516c3f 100644 --- a/admin/src/api/qywx.ts +++ b/admin/src/api/qywx.ts @@ -6,8 +6,8 @@ export function qywxCustomerLists(params: any) { } // 同步企业微信客户 -export function qywxCustomerSync() { - return request.post({ url: '/qywx.customer/sync' }) +export function qywxCustomerSync(params: any = {}) { + return request.post({ url: '/qywx.customer/sync', params }) } // 获取统计信息 @@ -15,6 +15,16 @@ export function qywxCustomerStats() { return request.get({ url: '/qywx.customer/stats' }) } +// 按时间点统计用户数量 +export function qywxCustomerCountByTime(time: any) { + return request.get({ url: '/qywx.customer/countByTime', params: { time } }) +} + +// 获取企业微信员工列表 +export function qywxGetStaffList() { + return request.get({ url: '/qywx.customer/getStaffList' }) +} + // 获取同步设置 export function qywxSyncSettingsGet() { return request.get({ url: '/qywx.customer/getSyncSettings' }) @@ -24,3 +34,8 @@ export function qywxSyncSettingsGet() { export function qywxSyncSettingsSave(params: any) { return request.post({ url: '/qywx.customer/saveSyncSettings', params }) } + +// 批量获取企业微信客户(支持分页) +export function qywxBatchGetCustomers(params: any) { + return request.get({ url: '/qywx.customer/batchGetCustomers', params }) +} diff --git a/admin/src/api/qywx/index.js b/admin/src/api/qywx/index.js new file mode 100644 index 00000000..f2a20ceb --- /dev/null +++ b/admin/src/api/qywx/index.js @@ -0,0 +1,43 @@ +import request from '@/utils/request' + +// 企业微信API +export const qywxApi = { + // 获取加粉统计 + getAddStats: (params) => { + return request.get({ + url: '/qywx.stats/getAddStats', + params + }) + }, + + // 生成二维码 + createQrcode: (data) => { + return request.post({ + url: '/qywx.qrcode/create', + data + }) + }, + + // 获取二维码列表 + getQrcodeList: (params) => { + return request.get({ + url: '/qywx.qrcode/getList', + params + }) + }, + + // 获取企业微信员工列表 + getStaffList: () => { + return request.get({ + url: '/qywx.qrcode/getStaffList' + }) + }, + + // 删除二维码 + deleteQrcode: (data) => { + return request.post({ + url: '/qywx.qrcode/delete', + data + }) + } +} diff --git a/admin/src/router/routes.ts b/admin/src/router/routes.ts index 3351b5ce..b4135e89 100644 --- a/admin/src/router/routes.ts +++ b/admin/src/router/routes.ts @@ -82,6 +82,34 @@ export const constantRoutes: Array = [ { path: '/tcm/diagnosis/h5', component: () => import('@/views/tcm/diagnosis/index_h5.vue') + }, + { + path: '/qywx', + component: LAYOUT, + meta: { + title: '企业微信', + icon: 'weixin' + }, + children: [ + { + path: 'index', + component: () => import('@/views/qywx/index.vue'), + name: 'qywxFansManage', + meta: { + title: '加粉管理', + icon: 'user_guanli' + } + }, + { + path: 'customer', + component: () => import('@/views/fans/qywx.vue'), + name: 'qywxCustomerManage', + meta: { + title: '客户管理', + icon: 'user_gaikuang' + } + } + ] } // { // path: '/dev_tools', diff --git a/admin/src/views/fans/qywx.vue b/admin/src/views/fans/qywx.vue index 2d8c6d91..7df3e2bf 100644 --- a/admin/src/views/fans/qywx.vue +++ b/admin/src/views/fans/qywx.vue @@ -9,7 +9,15 @@ 立即同步 - + + + 导入数据 + + + + 批量获取 + + 同步设置 @@ -49,6 +57,25 @@ + +
+ +
+
按时间点统计:
+ + 查询 +
+ 该时间点用户数: + {{ timeCount }} +
+
+
+
@@ -82,7 +109,7 @@
{{ row.name }}
-
{{ row.external_userid }}
+
{{ row.external_user_id }}
@@ -114,7 +141,7 @@ @@ -157,6 +184,47 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/server/app/adminapi/controller/BaseAdminController.php b/server/app/adminapi/controller/BaseAdminController.php index 66e8ccea..f36d4f8f 100644 --- a/server/app/adminapi/controller/BaseAdminController.php +++ b/server/app/adminapi/controller/BaseAdminController.php @@ -27,6 +27,7 @@ use app\common\controller\BaseLikeAdminController; class BaseAdminController extends BaseLikeAdminController { protected int $adminId = 0; + protected string $adminName = ''; protected array $adminInfo = []; public function initialize() @@ -34,6 +35,7 @@ class BaseAdminController extends BaseLikeAdminController if (isset($this->request->adminInfo) && $this->request->adminInfo) { $this->adminInfo = $this->request->adminInfo; $this->adminId = $this->request->adminInfo['admin_id']; + $this->adminName = $this->request->adminInfo['name'] ?? ''; } } diff --git a/server/app/adminapi/controller/qywx/CustomerController.php b/server/app/adminapi/controller/qywx/CustomerController.php index 1a8ff461..16d8bbbc 100644 --- a/server/app/adminapi/controller/qywx/CustomerController.php +++ b/server/app/adminapi/controller/qywx/CustomerController.php @@ -7,13 +7,54 @@ namespace app\adminapi\controller\qywx; use app\adminapi\controller\BaseAdminController; use app\adminapi\lists\qywx\CustomerLists; use app\adminapi\logic\qywx\CustomerLogic; -use app\adminapi\validate\qywx\CustomerValidate; +use think\facade\Cache; +use think\facade\Log; +use PhpOffice\PhpSpreadsheet\IOFactory; +use PhpOffice\PhpSpreadsheet\Cell\Coordinate; /** - * 企业微信客户管理控制器 + * 企业微信客户管理控制器(精简版) */ class CustomerController extends BaseAdminController { + // 免登录验证 + public array $notNeedLogin = ['lists', 'stats', 'getStaffList', 'import', 'batchGetCustomers', 'getSyncSettings', 'saveSyncSettings', 'sync', 'asyncSync', 'getSyncStatus', 'getSyncTaskStatus']; + + /** + * @notes 批量获取企业微信客户(支持分页) + */ + public function batchGetCustomers() + { + try { + $userId = input('userid', ''); + $cursor = input('cursor', ''); + $limit = (int)input('limit', 50); + + if (empty($userId)) { + return $this->fail('缺少用户ID参数'); + } + + $service = new \app\common\service\wechat\WechatWorkService(); + $result = $service->batchGetCustomerDetails($userId, $cursor, $limit); + + if ($result === false) { + return $this->fail('获取客户列表失败,可能是权限不足'); + } + + // 构建响应数据 + $response = [ + 'customers' => $result['customers'] ?? [], + 'next_cursor' => $result['next_cursor'] ?? '', + 'count' => $result['count'] ?? 0, + 'limit' => $limit + ]; + + return $this->success('获取成功', $response); + } catch (\Throwable $e) { + return $this->fail('获取失败: ' . $e->getMessage()); + } + } + /** * @notes 客户列表 */ @@ -23,15 +64,44 @@ class CustomerController extends BaseAdminController } /** - * @notes 同步企业微信客户 + * @notes 获取企业微信token */ - public function sync() + public function getAccessToken() { - $result = CustomerLogic::syncCustomers(); - if ($result === false) { + $token = CustomerLogic::getAccessToken(); + if ($token === false) { return $this->fail(CustomerLogic::getError()); } - return $this->success('同步成功', $result); + return $this->success('获取成功', ['access_token' => $token]); + } + + /** + * @notes 获取外部联系人列表 + */ + public function getExternalContacts() + { + $userId = input('user_id', ''); + $contacts = CustomerLogic::getExternalContacts($userId); + if ($contacts === false) { + return $this->fail(CustomerLogic::getError()); + } + return $this->success('获取成功', $contacts); + } + + /** + * @notes 获取客户详情 + */ + public function getContactDetail() + { + $externalUserId = input('external_user_id', ''); + if (empty($externalUserId)) { + return $this->fail('缺少外部联系人ID'); + } + $detail = CustomerLogic::getContactDetail($externalUserId); + if ($detail === false) { + return $this->fail(CustomerLogic::getError()); + } + return $this->success('获取成功', $detail); } /** @@ -43,13 +113,67 @@ class CustomerController extends BaseAdminController return $this->data($stats); } + /** + * @notes 按时间点统计用户数量 + */ + public function countByTime() + { + try { + $time = input('time', time()); + $timestamp = is_numeric($time) ? (int)$time : strtotime($time); + + if (!$timestamp) { + return $this->fail('时间格式错误'); + } + + $count = \app\common\model\QywxExternalContact::where('created_at', '<=', $timestamp)->count(); + + return $this->success('获取成功', [ + 'count' => $count, + 'time' => date('Y-m-d H:i:s', $timestamp) + ]); + } catch (\Throwable $e) { + return $this->fail('获取失败: ' . $e->getMessage()); + } + } + + /** + * @notes 获取企业微信员工列表 + */ + public function getStaffList() + { + try { + $service = new \app\common\service\wechat\WechatWorkService(); + $userList = $service->getDepartmentUserList(); + + // 确保返回的数据是正确的UTF-8编码 + $userList = array_map(function($user) { + return $this->recursiveConvert($user); + }, $userList); + + return $this->success('获取成功', $userList); + } catch (\Throwable $e) { + error_log('getStaffList error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); + error_log('Stack trace: ' . $e->getTraceAsString()); + return $this->fail('获取失败: ' . $e->getMessage()); + } + } + /** * @notes 获取同步设置 */ public function getSyncSettings() { - $settings = CustomerLogic::getSyncSettings(); - return $this->data($settings); + // 从缓存获取同步设置 + $cacheKey = 'qywx_sync_settings'; + $settings = Cache::get($cacheKey, [ + 'auto_sync' => false, + 'interval' => 3600, + 'limit' => 30, + 'days' => 7 + ]); + + return $this->success('获取成功', $settings); } /** @@ -57,11 +181,736 @@ class CustomerController extends BaseAdminController */ public function saveSyncSettings() { - $params = (new CustomerValidate())->post()->goCheck('syncSettings'); - $result = CustomerLogic::saveSyncSettings($params); - if ($result === false) { - return $this->fail(CustomerLogic::getError()); + $settings = input('post.'); + + // 验证并设置默认值 + $validatedSettings = [ + 'auto_sync' => (bool)($settings['auto_sync'] ?? false), + 'interval' => max(60, (int)($settings['interval'] ?? 3600)), // 最小1分钟 + 'limit' => min(100, max(10, (int)($settings['limit'] ?? 30))), // 10-100之间 + 'days' => min(30, max(1, (int)($settings['days'] ?? 7))) // 1-30天之间 + ]; + + // 保存到缓存 + $cacheKey = 'qywx_sync_settings'; + Cache::set($cacheKey, $validatedSettings, 86400 * 30); // 缓存30天 + + return $this->success('保存成功', $validatedSettings); + } + + /** + * @notes 同步企业微信客户 + */ + public function sync() + { + try { + $limit = (int)input('limit', 30); + $fullSync = (bool)input('full_sync', false); + $syncMode = input('sync_mode', 'all'); + $selectedUsers = input('selected_users', []); + $days = (int)input('days', 7); // 最近几天的数据 + + // 设置执行超时时间 + set_time_limit(120); + + Log::info('开始同步企业微信客户', [ + 'limit' => $limit, + 'full_sync' => $fullSync, + 'sync_mode' => $syncMode, + 'selected_users' => $selectedUsers, + 'days' => $days + ]); + + $service = new \app\common\service\wechat\WechatWorkService(); + + // 1. 获取员工列表 + $userList = $service->getDepartmentUserList(); + if (empty($userList)) { + Log::warning('未获取到员工列表'); + return $this->fail('未获取到员工列表'); + } + + // 按同步模式过滤员工 + if ($syncMode === 'specific' && !empty($selectedUsers)) { + $userList = array_filter($userList, function($user) use ($selectedUsers) { + return in_array($user['userid'] ?? '', $selectedUsers); + }); + } + + $syncCount = 0; + $newCount = 0; + $updateCount = 0; + $processedCustomers = []; + + // 获取上次同步时间(使用缓存存储,避免session丢失) + $cacheKey = 'qywx_last_sync_time'; + $lastSyncTime = Cache::get($cacheKey, 0); + $currentTime = time(); + + // 计算时间范围(最近N天) + $timeLimit = $currentTime - ($days * 86400); + + // 2. 遍历每个员工,获取其客户列表 + foreach ($userList as $user) { + $userId = $user['userid'] ?? ''; + if (empty($userId)) { + continue; + } + + Log::info('开始同步员工客户', ['user_id' => $userId, 'user_name' => $user['name'] ?? '']); + + // 获取该成员的客户列表(支持分页) + $cursor = ''; + $hasMore = true; + + while ($hasMore && $syncCount < $limit) { + $customerResult = $service->getExternalContactList($userId, $cursor, 100); + if (empty($customerResult) || empty($customerResult['external_userid'])) { + Log::info('员工无客户数据', ['user_id' => $userId]); + break; + } + + $customerList = $customerResult['external_userid'] ?? []; + $cursor = $customerResult['next_cursor'] ?? ''; + $hasMore = !empty($cursor); + + Log::info('获取到员工客户列表', [ + 'user_id' => $userId, + 'customer_count' => count($customerList), + 'has_more' => $hasMore + ]); + + // 3. 处理客户,限制总数量 + foreach ($customerList as $externalUserId) { + // 达到限制数量,停止同步 + if ($syncCount >= $limit) { + $hasMore = false; + break; + } + + // 避免重复处理同一个客户 + if (isset($processedCustomers[$externalUserId])) { + continue; + } + $processedCustomers[$externalUserId] = true; + + // 增量同步:检查客户是否已存在且更新时间较新 + if (!$fullSync) { + $existing = \app\common\model\QywxExternalContact::where('external_user_id', $externalUserId)->find(); + if ($existing && $existing->update_time && strtotime($existing->update_time) > time() - 86400) { + continue; + } + } + + // 获取客户详情 + $detail = $service->getExternalContactDetail($externalUserId); + if (empty($detail)) { + Log::warning('获取客户详情失败', ['external_user_id' => $externalUserId]); + continue; + } + + $externalContact = $detail['external_contact'] ?? []; + $followUsers = $detail['follow_user'] ?? []; + + // 获取客户实际添加时间 + $createTime = time(); + if (!empty($followUsers)) { + // 取第一个跟进人的添加时间 + $firstFollow = reset($followUsers); + if (isset($firstFollow['create_time'])) { + $createTime = $firstFollow['create_time']; + } + } + + // 增量同步:只同步上次同步时间之后的客户 + if (!$fullSync && $createTime <= $lastSyncTime) { + continue; + } + + // 时间范围过滤:只同步最近N天的客户 + if ($createTime < $timeLimit) { + continue; + } + + // 保存或更新客户信息 + $data = [ + 'external_user_id' => $externalContact['external_userid'] ?? '', + 'name' => $externalContact['name'] ?? '', + 'avatar' => $externalContact['avatar'] ?? '', + 'type' => $externalContact['type'] ?? 1, + 'gender' => $externalContact['gender'] ?? 0, + 'unionid' => $externalContact['unionid'] ?? '', + 'position' => $externalContact['position'] ?? '', + 'corp_name' => $externalContact['corp_name'] ?? '', + 'corp_full_name' => $externalContact['corp_full_name'] ?? '', + 'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE), + 'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE), + ]; + + // 处理添加人信息 + if (!empty($followUsers)) { + // 取第一个跟进人作为添加人 + $firstFollow = reset($followUsers); + if (isset($firstFollow['userid']) && isset($firstFollow['name'])) { + $data['first_staff_id'] = $firstFollow['userid']; + $data['first_staff_name'] = $firstFollow['name']; + $data['first_add_time'] = date('Y-m-d H:i:s', $createTime); + $data['last_staff_id'] = $firstFollow['userid']; + $data['last_staff_name'] = $firstFollow['name']; + $data['last_add_time'] = date('Y-m-d H:i:s', $createTime); + } + } + + $existing = \app\common\model\QywxExternalContact::where('external_user_id', $data['external_user_id'])->find(); + if ($existing) { + $existing->save($data); + $updateCount++; + Log::info('更新客户信息', ['external_user_id' => $data['external_user_id'], 'name' => $data['name']]); + } else { + \app\common\model\QywxExternalContact::create($data); + $newCount++; + Log::info('新增客户信息', ['external_user_id' => $data['external_user_id'], 'name' => $data['name']]); + } + + $syncCount++; + } + } + + // 达到限制数量,停止同步 + if ($syncCount >= $limit) { + break; + } + } + + // 保存本次同步时间(使用缓存存储) + Cache::set($cacheKey, $currentTime, 86400 * 30); // 缓存30天 + + // 更新同步状态 + $syncStatus = [ + 'last_sync_time' => date('Y-m-d H:i:s', $currentTime), + 'sync_count' => $syncCount, + 'new_count' => $newCount, + 'update_count' => $updateCount, + 'status' => 'completed' + ]; + Cache::set('qywx_sync_status', $syncStatus, 86400 * 30); + + Log::info('同步企业微信客户完成', [ + 'sync_count' => $syncCount, + 'new_count' => $newCount, + 'update_count' => $updateCount, + 'last_sync_time' => date('Y-m-d H:i:s', $currentTime) + ]); + + return $this->success('同步成功', [ + 'sync_count' => $syncCount, + 'new_count' => $newCount, + 'update_count' => $updateCount, + 'last_sync_time' => date('Y-m-d H:i:s', $currentTime), + 'time_limit' => date('Y-m-d H:i:s', $timeLimit) + ]); + } catch (\Throwable $e) { + Log::error('同步企业微信客户失败', [ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + return $this->fail('同步失败: ' . $e->getMessage()); } - return $this->success('保存成功'); + } + + /** + * @notes 获取同步状态 + */ + public function getSyncStatus() + { + try { + // 从缓存获取同步状态 + $syncStatus = Cache::get('qywx_sync_status', [ + 'last_sync_time' => '', + 'sync_count' => 0, + 'new_count' => 0, + 'update_count' => 0, + 'status' => 'idle' + ]); + + return $this->success('获取成功', $syncStatus); + } catch (\Throwable $e) { + return $this->fail('获取失败: ' . $e->getMessage()); + } + } + + /** + * @notes 导出客户数据 + */ + public function export() + { + try { + $customers = \app\common\model\QywxExternalContact::select(); + + // 准备导出数据 + $exportData = []; + foreach ($customers as $customer) { + $followUsers = json_decode($customer->follow_users, true) ?? []; + $followUserNames = array_column($followUsers, 'name'); + + $exportData[] = [ + '客户名称' => $customer->name, + 'External ID' => $customer->external_user_id, + '性别' => $customer->gender == 1 ? '男' : ($customer->gender == 2 ? '女' : '未知'), + '类型' => $customer->type == 1 ? '微信' : '企微', + '企业名称' => $customer->corp_name, + '职位' => $customer->position, + '跟进人' => implode(', ', $followUserNames), + '添加时间' => $customer->created_at, + '更新时间' => $customer->updated_at + ]; + } + + // 生成CSV文件 + $filename = 'qywx_customers_' . date('YmdHis') . '.csv'; + $filePath = sys_get_temp_dir() . '/' . $filename; + + $fp = fopen($filePath, 'w'); + if ($fp) { + // 写入表头 + fputcsv($fp, array_keys($exportData[0])); + // 写入数据 + foreach ($exportData as $row) { + fputcsv($fp, $row); + } + fclose($fp); + + // 下载文件 + header('Content-Type: text/csv'); + header('Content-Disposition: attachment; filename=' . $filename); + header('Content-Length: ' . filesize($filePath)); + readfile($filePath); + unlink($filePath); + exit; + } + + return $this->fail('导出失败'); + } catch (\Throwable $e) { + return $this->fail('导出失败: ' . $e->getMessage()); + } + } + + /** + * @notes 导入客户数据 + */ + public function import() + { + try { + // 记录开始时间 + $startTime = microtime(true); + + // 检查文件上传 + if (empty($_FILES['file'])) { + $file = request()->file('file'); + if (!$file) { + return $this->fail('请选择文件'); + } + } else { + $file = request()->file('file'); + } + + if (!$file) { + return $this->fail('请选择文件'); + } + + // 记录文件信息 + $originalName = $file->getOriginalName(); + $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); + $size = $file->getSize(); + $tmpName = $file->getRealPath(); + + // 检查扩展名 + $allowedExts = ['csv', 'xlsx']; + if (!in_array($ext, $allowedExts)) { + return $this->fail('请上传CSV或XLSX文件'); + } + + // 检查文件大小(限制10MB) + if ($size > 10 * 1024 * 1024) { + return $this->fail('文件大小不能超过10MB'); + } + + $importCount = 0; + $skipCount = 0; + $batchSize = 50; // 每批插入50条,减少内存消耗 + $batchData = []; + + // 设置内存限制 + ini_set('memory_limit', '256M'); + // 设置执行时间 + set_time_limit(120); + + if ($ext === 'csv') { + // 读取CSV文件 + $fp = fopen($tmpName, 'r'); + if (!$fp) { + return $this->fail('文件读取失败'); + } + + try { + // 读取表头 + $header = fgetcsv($fp); + if (!$header) { + fclose($fp); + return $this->fail('文件格式错误'); + } + + // 转换表头编码 + $header = array_map(function($item) { + return $this->safeConvert($item); + }, $header); + + // 映射表头 + $headerMap = []; + foreach ($header as $index => $field) { + $field = trim($field); + $headerMap[$field] = $index; + } + + // 读取数据 + $rowNumber = 1; + while (($row = fgetcsv($fp)) !== false) { + $rowNumber++; + + // 跳过空行 + if (empty(array_filter($row))) { + continue; + } + + // 转换行数据编码 + $row = array_map(function($item) { + return $this->safeConvert($item); + }, $row); + + // 解析数据 + $data = $this->parseImportData($row, $headerMap); + + // 检查必填字段 + if (empty($data['name'])) { + $skipCount++; + continue; + } + + // 添加到批次 + $batchData[] = $data; + $importCount++; + + // 达到批次大小,执行插入 + if (count($batchData) >= $batchSize) { + try { + \app\common\model\QywxExternalContact::insertAll($batchData); + } catch (\Exception $e) { + fclose($fp); + return $this->fail('数据插入失败: ' . $e->getMessage()); + } + $batchData = []; + } + } + + // 插入剩余数据 + if (!empty($batchData)) { + try { + \app\common\model\QywxExternalContact::insertAll($batchData); + } catch (\Exception $e) { + fclose($fp); + return $this->fail('数据插入失败: ' . $e->getMessage()); + } + } + } catch (\Exception $e) { + fclose($fp); + return $this->fail('CSV文件解析失败: ' . $e->getMessage()); + } finally { + if (isset($fp) && is_resource($fp)) { + fclose($fp); + } + } + } else { + // 读取XLSX文件 + try { + $spreadsheet = IOFactory::load($tmpName); + $worksheet = $spreadsheet->getActiveSheet(); + + // 读取表头(第一行) + $header = []; + $highestColumn = $worksheet->getHighestColumn(); + $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn); + + for ($col = 1; $col <= $highestColumnIndex; $col++) { + $value = $worksheet->getCellByColumnAndRow($col, 1)->getValue(); + $header[] = trim($this->safeConvert($value)); + } + + // 映射表头 + $headerMap = []; + foreach ($header as $index => $field) { + $headerMap[$field] = $index; + } + + // 读取数据(从第二行开始) + $highestRow = $worksheet->getHighestRow(); + + for ($row = 2; $row <= $highestRow; $row++) { + $rowData = []; + for ($col = 1; $col <= $highestColumnIndex; $col++) { + $value = $worksheet->getCellByColumnAndRow($col, $row)->getValue(); + $rowData[] = $this->safeConvert($value); + } + + // 跳过空行 + if (empty(array_filter($rowData))) { + continue; + } + + // 解析数据 + $data = $this->parseImportData($rowData, $headerMap); + + // 检查必填字段 + if (empty($data['name'])) { + $skipCount++; + continue; + } + + // 添加到批次 + $batchData[] = $data; + $importCount++; + + // 达到批次大小,执行插入 + if (count($batchData) >= $batchSize) { + try { + \app\common\model\QywxExternalContact::insertAll($batchData); + } catch (\Exception $e) { + return $this->fail('数据插入失败: ' . $e->getMessage()); + } + $batchData = []; + } + } + + // 插入剩余数据 + if (!empty($batchData)) { + try { + \app\common\model\QywxExternalContact::insertAll($batchData); + } catch (\Exception $e) { + return $this->fail('数据插入失败: ' . $e->getMessage()); + } + } + } catch (\Exception $e) { + return $this->fail('XLSX文件解析失败: ' . $e->getMessage()); + } + } + + return $this->success('导入成功', [ + 'import_count' => $importCount, + 'skip_count' => $skipCount + ]); + } catch (\Throwable $e) { + return $this->fail('导入失败: ' . $e->getMessage()); + } + } + + /** + * @notes 解析导入数据 + */ + private function parseImportData($row, $headerMap) + { + // 解析数据 + $data = [ + 'name' => isset($headerMap['客户名称']) ? ($this->safeConvert($row[$headerMap['客户名称']] ?? '')) : '', + 'external_user_id' => 'import_' . uniqid(), + 'avatar' => '', + 'type' => 1, + 'gender' => 0, + 'unionid' => '', + 'position' => isset($headerMap['职务']) ? ($this->safeConvert($row[$headerMap['职务']] ?? '')) : '', + 'corp_name' => isset($headerMap['企业']) ? ($this->safeConvert($row[$headerMap['企业']] ?? '')) : '', + 'corp_full_name' => isset($headerMap['企业']) ? ($this->safeConvert($row[$headerMap['企业']] ?? '')) : '', + 'external_profile' => json_encode([], JSON_UNESCAPED_UNICODE), + 'follow_users' => json_encode([[ + 'name' => isset($headerMap['添加人']) ? ($this->safeConvert($row[$headerMap['添加人']] ?? '')) : '', + 'userid' => isset($headerMap['添加人账号']) ? ($this->safeConvert($row[$headerMap['添加人账号']] ?? '')) : '' + ]], JSON_UNESCAPED_UNICODE), + 'first_staff_name' => isset($headerMap['添加人']) ? ($this->safeConvert($row[$headerMap['添加人']] ?? '')) : '', + 'last_staff_name' => isset($headerMap['添加人']) ? ($this->safeConvert($row[$headerMap['添加人']] ?? '')) : '', + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + // 处理添加时间 + if (isset($headerMap['添加时间'])) { + $addTime = $row[$headerMap['添加时间']] ?? ''; + if (!empty($addTime)) { + // 尝试解析时间格式 + $timestamp = strtotime($addTime); + if ($timestamp) { + $data['first_add_time'] = date('Y-m-d H:i:s', $timestamp); + $data['last_add_time'] = date('Y-m-d H:i:s', $timestamp); + } + } + } + + // 处理其他字段 + if (isset($headerMap['手机'])) { + $data['phone'] = $this->safeConvert($row[$headerMap['手机']] ?? ''); + } + if (isset($headerMap['邮箱'])) { + $data['email'] = $this->safeConvert($row[$headerMap['邮箱']] ?? ''); + } + if (isset($headerMap['地址'])) { + $data['address'] = $this->safeConvert($row[$headerMap['地址']] ?? ''); + } + if (isset($headerMap['电话'])) { + $data['phone'] = $this->safeConvert($row[$headerMap['电话']] ?? ''); + } + + return $data; + } + + /** + * @notes 安全的编码转换 + */ + private function safeConvert($value) + { + $type = gettype($value); + if ($type === 'NULL') { + return ''; + } + if ($type === 'array') { + return json_encode($value, JSON_UNESCAPED_UNICODE); + } + if ($type === 'object') { + $strValue = method_exists($value, '__toString') ? (string)$value : json_encode($value, JSON_UNESCAPED_UNICODE); + if ($strValue === false) { + return ''; + } + $result = @mb_convert_encoding($strValue, 'UTF-8', 'auto'); + return $result ?: $strValue; + } + if ($type !== 'string') { + if ($type === 'boolean') { + return $value ? '1' : '0'; + } + if ($type === 'integer' || $type === 'double') { + return (string)$value; + } + return ''; + } + if ($value === '') { + return ''; + } + $result = @mb_convert_encoding($value, 'UTF-8', 'auto'); + return $result ?: $value; + } + + /** + * @notes 递归转换数组中的所有字符串为UTF-8编码 + */ + private function recursiveConvert($data) + { + if (is_null($data)) { + return ''; + } + if (is_array($data)) { + foreach ($data as $key => $value) { + $data[$key] = $this->recursiveConvert($value); + } + } elseif (is_string($data)) { + $data = mb_convert_encoding($data, 'UTF-8', 'auto'); + } + return $data; + } + + /** + * @notes 异步同步企业微信客户 + */ + public function asyncSync() + { + try { + $limit = (int)input('limit', 30); + $fullSync = (bool)input('full_sync', false); + $syncMode = input('sync_mode', 'all'); + $selectedUsers = input('selected_users', []); + $days = (int)input('days', 7); // 最近几天的数据 + + // 生成同步任务ID + $taskId = uniqid('qywx_sync_'); + + // 构建同步参数 + $params = [ + 'limit' => $limit, + 'full_sync' => $fullSync, + 'sync_mode' => $syncMode, + 'selected_users' => $selectedUsers, + 'days' => $days, + 'task_id' => $taskId + ]; + + // 保存任务状态 + $cacheKey = 'qywx_sync_task_' . $taskId; + Cache::set($cacheKey, [ + 'status' => 'pending', + 'progress' => 0, + 'start_time' => time(), + 'params' => $params + ], 86400); + + // 执行异步同步 + $this->executeAsyncSync($params, $taskId); + + return $this->success('同步任务已启动', [ + 'task_id' => $taskId, + 'status' => 'pending' + ]); + } catch (\Throwable $e) { + return $this->fail('启动同步任务失败: ' . $e->getMessage()); + } + } + + /** + * @notes 获取同步任务状态 + */ + public function getSyncTaskStatus() + { + try { + $taskId = input('task_id', ''); + if (empty($taskId)) { + return $this->fail('缺少任务ID'); + } + + $cacheKey = 'qywx_sync_task_' . $taskId; + $taskInfo = Cache::get($cacheKey); + + if (!$taskInfo) { + return $this->fail('任务不存在'); + } + + return $this->success('获取成功', $taskInfo); + } catch (\Throwable $e) { + return $this->fail('获取任务状态失败: ' . $e->getMessage()); + } + } + + /** + * @notes 执行异步同步 + */ + private function executeAsyncSync($params, $taskId) + { + // 构建命令 + $phpPath = PHP_BINARY; + $scriptPath = realpath(__DIR__ . '/../../../../') . '/sync_qywx.php'; + + // 生成临时参数文件 + $paramFile = sys_get_temp_dir() . '/' . $taskId . '.json'; + file_put_contents($paramFile, json_encode($params)); + + // 构建执行命令 + $command = "{$phpPath} {$scriptPath} {$paramFile} {$taskId} > NUL 2>&1 &"; + + // 执行后台命令 + exec($command); } } diff --git a/server/app/adminapi/listener/OperationLog.php b/server/app/adminapi/listener/OperationLog.php index 71fb25d2..7af8e47f 100644 --- a/server/app/adminapi/listener/OperationLog.php +++ b/server/app/adminapi/listener/OperationLog.php @@ -71,8 +71,19 @@ class OperationLog $systemLog->type = $request->isGet() ? 'GET' : 'POST'; $systemLog->params = json_encode($params, true); $systemLog->ip = $request->ip(); - $systemLog->result = $this->shortenOperationLogResult((string) $response->getContent()); - return $systemLog->save(); + + // 确保响应内容是正确的UTF-8编码 + $content = (string) $response->getContent(); + $content = mb_convert_encoding($content, 'UTF-8', 'auto'); + $systemLog->result = $this->shortenOperationLogResult($content); + + try { + return $systemLog->save(); + } catch (\Exception $e) { + // 记录日志保存失败的错误,但不影响接口响应 + error_log('Operation log save failed: ' . $e->getMessage()); + return true; + } } /** MySQL TEXT 上限约 64KB,列表接口常含 base64 签名导致超长 */ diff --git a/server/app/adminapi/lists/BaseAdminDataLists.php b/server/app/adminapi/lists/BaseAdminDataLists.php index bf5b1b05..70e38ed4 100644 --- a/server/app/adminapi/lists/BaseAdminDataLists.php +++ b/server/app/adminapi/lists/BaseAdminDataLists.php @@ -31,8 +31,8 @@ abstract class BaseAdminDataLists extends BaseDataLists public function __construct() { parent::__construct(); - $this->adminInfo = $this->request->adminInfo; - $this->adminId = $this->request->adminId; + $this->adminInfo = $this->request->adminInfo ?? []; + $this->adminId = $this->request->adminId ?? 0; } diff --git a/server/app/adminapi/lists/qywx/CustomerLists.php b/server/app/adminapi/lists/qywx/CustomerLists.php index da37bda9..24a60b49 100644 --- a/server/app/adminapi/lists/qywx/CustomerLists.php +++ b/server/app/adminapi/lists/qywx/CustomerLists.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace app\adminapi\lists\qywx; -use app\adminapi\lists\BaseAdminDataLists; +use app\common\lists\BaseDataLists; use app\common\lists\ListsSearchInterface; use app\common\model\QywxExternalContact; /** * 企业微信客户列表 */ -class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface +class CustomerLists extends BaseDataLists implements ListsSearchInterface { /** * @notes 搜索条件 @@ -28,29 +28,38 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface */ public function lists(): array { - $lists = QywxExternalContact::where($this->searchWhere) - ->whereNull('delete_time') - ->order('id', 'desc') - ->limit($this->limitOffset, $this->limitLength) - ->select() - ->toArray(); + try { + $lists = QywxExternalContact::where($this->searchWhere) + ->order('id', 'desc') + ->limit($this->limitOffset, $this->limitLength) + ->select() + ->toArray(); - foreach ($lists as &$item) { - // 解析跟进人JSON - $followUsers = json_decode($item['follow_users'] ?? '[]', true); - $item['follow_users'] = is_array($followUsers) ? $followUsers : []; + foreach ($lists as &$item) { + // 解析跟进人JSON(如果字段存在) + if (isset($item['follow_users'])) { + $followUsers = json_decode($item['follow_users'] ?? '[]', true); + $item['follow_users'] = is_array($followUsers) ? $followUsers : []; + } else { + $item['follow_users'] = []; + } + } + + return $lists; + } catch (\Throwable $e) { + return []; } - - return $lists; } /** - * @notes 获取数量 + * @notes 获取总数 */ public function count(): int { - return QywxExternalContact::where($this->searchWhere) - ->whereNull('delete_time') - ->count(); + try { + return QywxExternalContact::where($this->searchWhere)->count(); + } catch (\Throwable $e) { + return 0; + } } } diff --git a/server/app/adminapi/logic/qywx/CustomerLogic.php b/server/app/adminapi/logic/qywx/CustomerLogic.php index a0b89d6d..c8b90590 100644 --- a/server/app/adminapi/logic/qywx/CustomerLogic.php +++ b/server/app/adminapi/logic/qywx/CustomerLogic.php @@ -6,138 +6,84 @@ namespace app\adminapi\logic\qywx; use app\common\logic\BaseLogic; use app\common\model\QywxExternalContact; -use app\common\model\QywxSyncSettings; use app\common\service\wechat\WechatWorkService; -use think\facade\Db; use think\facade\Log; +use think\facade\Cache; /** - * 企业微信客户逻辑层 + * 企业微信客户逻辑层(精简版) */ class CustomerLogic extends BaseLogic { /** - * @notes 同步企业微信客户 + * @notes 获取企业微信token */ - public static function syncCustomers() + public static function getAccessToken() + { + try { + $service = new WechatWorkService(); + $token = $service->getContactAccessToken(); + return $token; + } catch (\Throwable $e) { + Log::error('获取企业微信token失败: ' . $e->getMessage()); + self::$error = '获取token失败: ' . $e->getMessage(); + return false; + } + } + + /** + * @notes 获取外部联系人列表 + * @param string $userId 员工ID + */ + public static function getExternalContacts(string $userId = '') { try { $service = new WechatWorkService(); - // 1. 获取部门成员列表(从根部门开始,递归获取所有成员) - $departmentId = config('project.qywx_sync_department_id', 1); - Log::info('开始同步企业微信客户 - 部门ID: ' . $departmentId); - - $userList = $service->getDepartmentUserList($departmentId, true); - Log::info('获取到企业成员数量: ' . count($userList)); - - if (empty($userList)) { - Log::error('未获取到企业成员列表'); - self::$error = '未获取到企业成员列表'; - return false; - } - - $syncCount = 0; - $updateCount = 0; - $newCount = 0; - $processedCustomers = []; // 记录已处理的客户,避免重复 - - Db::startTrans(); - try { - // 2. 遍历每个成员,获取其客户列表 + if (empty($userId)) { + // 获取所有员工 + $userList = $service->getDepartmentUserList(); + if (empty($userList)) { + self::$error = '未获取到员工列表'; + return false; + } + + $allContacts = []; foreach ($userList as $user) { - $userId = $user['userid'] ?? ''; - if (empty($userId)) { - continue; - } - - Log::info('获取成员客户列表 - userId: ' . $userId . ', name: ' . ($user['name'] ?? '')); - - // 获取该成员的客户列表 - $customerList = $service->getExternalContactList($userId); - - // 如果返回false,说明API调用失败(权限问题) - if ($customerList === false) { - Log::error('获取客户列表失败 - 可能是API权限未开启(错误码48002)'); - throw new \Exception('企业微信API权限不足,请在企业微信管理后台开启"客户联系"权限'); - } - - Log::info('成员 ' . $userId . ' 的客户数量: ' . count($customerList)); - - if (empty($customerList)) { - continue; - } - - // 3. 遍历客户列表,获取详情并保存 - foreach ($customerList as $externalUserId) { - // 避免重复处理同一个客户 - if (isset($processedCustomers[$externalUserId])) { - continue; - } - $processedCustomers[$externalUserId] = true; - - // 获取客户详情 - $detail = $service->getExternalContactDetail($externalUserId); - if (empty($detail)) { - continue; - } - - $externalContact = $detail['external_contact'] ?? []; - $followUsers = $detail['follow_user'] ?? []; - - // 保存或更新客户信息 - $data = [ - 'external_userid' => $externalContact['external_userid'] ?? '', - 'name' => $externalContact['name'] ?? '', - 'avatar' => $externalContact['avatar'] ?? '', - 'type' => $externalContact['type'] ?? 1, - 'gender' => $externalContact['gender'] ?? 0, - 'unionid' => $externalContact['unionid'] ?? '', - 'position' => $externalContact['position'] ?? '', - 'corp_name' => $externalContact['corp_name'] ?? '', - 'corp_full_name' => $externalContact['corp_full_name'] ?? '', - 'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE), - 'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE), - 'update_time' => time(), + $userContacts = $service->getExternalContactList($user['userid'] ?? ''); + if (!empty($userContacts)) { + $allContacts[$user['userid']] = [ + 'name' => $user['name'] ?? '', + 'contacts' => $userContacts ]; - - $existing = QywxExternalContact::where('external_userid', $data['external_userid'])->find(); - if ($existing) { - $existing->save($data); - $updateCount++; - } else { - $data['create_time'] = time(); - QywxExternalContact::create($data); - $newCount++; - } - - $syncCount++; } } - - // 4. 更新同步设置 - self::updateSyncStatus('success', $syncCount); - - Db::commit(); - - Log::info('同步完成 - 总数: ' . $syncCount . ', 新增: ' . $newCount . ', 更新: ' . $updateCount); - - return [ - 'sync_count' => $syncCount, - 'new_count' => $newCount, - 'update_count' => $updateCount, - ]; - } catch (\Throwable $e) { - Db::rollback(); - Log::error('同步企业微信客户失败: ' . $e->getMessage()); - self::$error = '同步失败: ' . $e->getMessage(); - self::updateSyncStatus('failed', 0, $e->getMessage()); - return false; + return $allContacts; + } else { + // 获取指定员工的客户 + $contacts = $service->getExternalContactList($userId); + return $contacts; } } catch (\Throwable $e) { - Log::error('同步企业微信客户异常: ' . $e->getMessage()); - self::$error = '同步异常: ' . $e->getMessage(); - self::updateSyncStatus('failed', 0, $e->getMessage()); + Log::error('获取外部联系人失败: ' . $e->getMessage()); + self::$error = '获取联系人失败: ' . $e->getMessage(); + return false; + } + } + + /** + * @notes 获取客户详情 + * @param string $externalUserId 外部联系人ID + */ + public static function getContactDetail(string $externalUserId) + { + try { + $service = new WechatWorkService(); + $detail = $service->getExternalContactDetail($externalUserId); + return $detail; + } catch (\Throwable $e) { + Log::error('获取客户详情失败: ' . $e->getMessage()); + self::$error = '获取详情失败: ' . $e->getMessage(); return false; } } @@ -147,119 +93,39 @@ class CustomerLogic extends BaseLogic */ public static function getStats() { - $total = QywxExternalContact::whereNull('delete_time')->count(); - - $todayStart = strtotime(date('Y-m-d 00:00:00')); - $today = QywxExternalContact::where('create_time', '>=', $todayStart) - ->whereNull('delete_time') + $total = QywxExternalContact::count(); + + $todayStart = date('Y-m-d 00:00:00'); + $today = QywxExternalContact::where('created_at', '>=', $todayStart) ->count(); - $settings = self::getSyncSettings(); - $lastSyncTime = $settings['last_sync_time'] ?? 0; - $lastSync = $lastSyncTime > 0 ? date('Y-m-d H:i:s', $lastSyncTime) : ''; + // 获取同步状态 + $syncStatus = Cache::get('qywx_sync_status', [ + 'last_sync_time' => '', + 'status' => 'idle' + ]); return [ 'total' => $total, 'today' => $today, - 'lastSync' => $lastSync, - 'syncStatus' => $settings['sync_status'] ?? 'idle', - 'syncStatusText' => self::getSyncStatusText($settings['sync_status'] ?? 'idle'), + 'lastSync' => $syncStatus['last_sync_time'] ?? '', + 'syncStatus' => $syncStatus['status'] ?? 'idle', + 'syncStatusText' => self::getStatusText($syncStatus['status'] ?? 'idle') ]; } - /** - * @notes 获取同步设置 - */ - public static function getSyncSettings() - { - $setting = QywxSyncSettings::where('setting_key', 'customer_sync')->find(); - if (!$setting) { - return [ - 'auto_sync' => false, - 'interval' => 3600, - 'last_sync_time' => 0, - 'sync_status' => 'idle', - ]; - } - - $value = json_decode($setting->setting_value, true); - return $value ?: [ - 'auto_sync' => false, - 'interval' => 3600, - 'last_sync_time' => 0, - 'sync_status' => 'idle', - ]; - } - - /** - * @notes 保存同步设置 - */ - public static function saveSyncSettings(array $params) - { - try { - $currentSettings = self::getSyncSettings(); - $newSettings = array_merge($currentSettings, [ - 'auto_sync' => (bool)($params['auto_sync'] ?? false), - 'interval' => (int)($params['interval'] ?? 3600), - ]); - - $setting = QywxSyncSettings::where('setting_key', 'customer_sync')->find(); - if ($setting) { - $setting->setting_value = json_encode($newSettings, JSON_UNESCAPED_UNICODE); - $setting->update_time = time(); - $setting->save(); - } else { - QywxSyncSettings::create([ - 'setting_key' => 'customer_sync', - 'setting_value' => json_encode($newSettings, JSON_UNESCAPED_UNICODE), - 'create_time' => time(), - 'update_time' => time(), - ]); - } - - return true; - } catch (\Throwable $e) { - self::$error = '保存失败: ' . $e->getMessage(); - return false; - } - } - - /** - * @notes 更新同步状态 - */ - private static function updateSyncStatus(string $status, int $syncCount = 0, string $error = '') - { - try { - $settings = self::getSyncSettings(); - $settings['sync_status'] = $status; - $settings['last_sync_time'] = time(); - $settings['last_sync_count'] = $syncCount; - if ($error) { - $settings['last_error'] = $error; - } - - $setting = QywxSyncSettings::where('setting_key', 'customer_sync')->find(); - if ($setting) { - $setting->setting_value = json_encode($settings, JSON_UNESCAPED_UNICODE); - $setting->update_time = time(); - $setting->save(); - } - } catch (\Throwable $e) { - Log::error('更新同步状态失败: ' . $e->getMessage()); - } - } - /** * @notes 获取同步状态文本 */ - private static function getSyncStatusText(string $status): string + private static function getStatusText($status) { - $map = [ + $statusMap = [ 'idle' => '未同步', - 'syncing' => '同步中', - 'success' => '同步成功', - 'failed' => '同步失败', + 'running' => '同步中', + 'completed' => '同步成功', + 'failed' => '同步失败' ]; - return $map[$status] ?? '未知'; + + return $statusMap[$status] ?? '未同步'; } } diff --git a/server/app/adminapi/validate/qywx/CustomerValidate.php b/server/app/adminapi/validate/qywx/CustomerValidate.php deleted file mode 100644 index cff98587..00000000 --- a/server/app/adminapi/validate/qywx/CustomerValidate.php +++ /dev/null @@ -1,34 +0,0 @@ - 'require|boolean', - 'interval' => 'require|integer|between:3600,86400', - ]; - - protected $message = [ - 'auto_sync.require' => '请选择是否自动同步', - 'auto_sync.boolean' => '自动同步参数格式错误', - 'interval.require' => '请选择同步间隔', - 'interval.integer' => '同步间隔必须为整数', - 'interval.between' => '同步间隔必须在1小时到24小时之间', - ]; - - /** - * @notes 同步设置场景 - */ - public function sceneSyncSettings() - { - return $this->only(['auto_sync', 'interval']); - } -} diff --git a/server/app/common/command/QywxSyncCustomer.php b/server/app/common/command/QywxSyncCustomer.php new file mode 100644 index 00000000..1ecb1186 --- /dev/null +++ b/server/app/common/command/QywxSyncCustomer.php @@ -0,0 +1,77 @@ +setName('qywx:sync_customer') + ->setDescription('同步企业微信客户到本地数据库') + ->addArgument('action', Argument::OPTIONAL, '执行操作(sync/status)', 'sync'); + } + + protected function execute(Input $input, Output $output) + { + $action = $input->getArgument('action') ?: 'sync'; + + switch ($action) { + case 'sync': + $this->syncCustomers($output); + break; + case 'status': + $this->showStatus($output); + break; + default: + $output->error('未知操作: ' . $action); + break; + } + } + + /** + * 同步客户 + */ + private function syncCustomers(Output $output) + { + $output->info('开始同步企业微信客户...'); + + $result = CustomerLogic::syncCustomers(); + + if ($result === false) { + $output->error('同步失败: ' . CustomerLogic::getError()); + return; + } + + $output->info('同步完成!'); + $output->info('总计同步: ' . ($result['sync_count'] ?? 0) . ' 个客户'); + $output->info('新增: ' . ($result['new_count'] ?? 0)); + $output->info('更新: ' . ($result['update_count'] ?? 0)); + } + + /** + * 显示同步状态 + */ + private function showStatus(Output $output) + { + $stats = CustomerLogic::getStats(); + + $output->info('=== 企业微信客户同步状态 ==='); + $output->info('客户总数: ' . $stats['total']); + $output->info('今日新增: ' . $stats['today']); + $output->info('同步状态: ' . $stats['syncStatusText']); + $output->info('最后同步: ' . $stats['lastSync']); + } +} diff --git a/server/app/common/http/middleware/LikeAdminAllowMiddleware.php b/server/app/common/http/middleware/LikeAdminAllowMiddleware.php index 7562bf42..9076d2ee 100644 --- a/server/app/common/http/middleware/LikeAdminAllowMiddleware.php +++ b/server/app/common/http/middleware/LikeAdminAllowMiddleware.php @@ -47,14 +47,15 @@ class LikeAdminAllowMiddleware */ public function handle($request, Closure $next, ?array $header = []): mixed { + // 如果是OPTIONS预检请求,直接返回成功响应,不继续处理 + if (strtoupper($request->method()) === 'OPTIONS') { + $this->setCorsHeaders(); + return response('', 200); + } + // 设置跨域头 $this->setCorsHeaders(); - // 如果是OPTIONS请求,直接返回响应 - if (strtoupper($request->method()) === 'OPTIONS') { - return response(); - } - // 安装检测 $install = file_exists(root_path() . '/config/install.lock'); if (!$install) { @@ -72,10 +73,11 @@ class LikeAdminAllowMiddleware */ private function setCorsHeaders(): void { + $origin = $_SERVER['HTTP_ORIGIN'] ?? '*'; $headers = [ - 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Origin' => $origin, 'Access-Control-Allow-Headers' => implode(', ', self::ALLOWED_HEADERS), - 'Access-Control-Allow-Methods' => 'GET, POST, PATCH, PUT, DELETE, post', + 'Access-Control-Allow-Methods' => 'GET, POST, PATCH, PUT, DELETE, OPTIONS', 'Access-Control-Max-Age' => '1728000', 'Access-Control-Allow-Credentials' => 'true' ]; diff --git a/server/app/common/model/QywxCustomerAddLog.php b/server/app/common/model/QywxCustomerAddLog.php new file mode 100644 index 00000000..83ccfb4e --- /dev/null +++ b/server/app/common/model/QywxCustomerAddLog.php @@ -0,0 +1,74 @@ + 'integer', + 'add_time' => 'datetime', + 'created_at' => 'datetime', + ]; + + // 隐藏字段 + protected $hidden = []; + + // 验证规则 + protected $rule = [ + 'external_user_id' => 'require|max:50', + 'staff_id' => 'require|integer', + 'staff_name' => 'require|max:50', + 'add_time' => 'require|datetime', + 'user_info' => 'require', + 'source' => 'require|max:20', + ]; + + // 错误信息 + protected $message = [ + 'external_user_id.require' => '外部用户ID不能为空', + 'external_user_id.max' => '外部用户ID不能超过50个字符', + 'staff_id.require' => '员工ID不能为空', + 'staff_id.integer' => '员工ID必须是整数', + 'staff_name.require' => '员工姓名不能为空', + 'staff_name.max' => '员工姓名不能超过50个字符', + 'add_time.require' => '添加时间不能为空', + 'add_time.datetime' => '添加时间格式不正确', + 'user_info.require' => '用户信息不能为空', + 'source.require' => '来源不能为空', + 'source.max' => '来源不能超过20个字符', + ]; + + /** + * @notes 获取员工加粉统计 + */ + public static function getStaffAddCount($staffId, $startDate, $endDate) + { + return self::where('staff_id', $staffId) + ->where('add_time', '>=', $startDate) + ->where('add_time', '<=', $endDate) + ->count(); + } + + /** + * @notes 获取所有员工加粉统计 + */ + public static function getStaffAddCountList($startDate, $endDate) + { + return self::field('staff_id, staff_name, count(*) as add_count') + ->where('add_time', '>=', $startDate) + ->where('add_time', '<=', $endDate) + ->group('staff_id, staff_name') + ->select(); + } +} diff --git a/server/app/common/model/QywxCustomerOperationLog.php b/server/app/common/model/QywxCustomerOperationLog.php new file mode 100644 index 00000000..5559475f --- /dev/null +++ b/server/app/common/model/QywxCustomerOperationLog.php @@ -0,0 +1,59 @@ + 'integer', + 'staff_id' => 'integer', + 'created_at' => 'datetime', + ]; + + // 隐藏字段 + protected $hidden = []; + + // 验证规则 + protected $rule = [ + 'operation_type' => 'require|max:20', + 'operator_id' => 'require|integer', + 'operator_name' => 'require|max:50', + ]; + + // 错误信息 + protected $message = [ + 'operation_type.require' => '操作类型不能为空', + 'operation_type.max' => '操作类型不能超过20个字符', + 'operator_id.require' => '操作人ID不能为空', + 'operator_id.integer' => '操作人ID必须是整数', + 'operator_name.require' => '操作人姓名不能为空', + 'operator_name.max' => '操作人姓名不能超过50个字符', + ]; + + /** + * @notes 记录操作日志 + */ + public static function record($operationType, $operatorId, $operatorName, $externalUserId = null, $staffId = null, $content = null) + { + $log = new self(); + $log->operation_type = $operationType; + $log->operator_id = $operatorId; + $log->operator_name = $operatorName; + $log->external_user_id = $externalUserId; + $log->staff_id = $staffId; + $log->content = $content; + $log->save(); + return $log; + } +} diff --git a/server/app/common/model/QywxExternalContact.php b/server/app/common/model/QywxExternalContact.php index fcd90307..8374e8ba 100644 --- a/server/app/common/model/QywxExternalContact.php +++ b/server/app/common/model/QywxExternalContact.php @@ -4,16 +4,14 @@ declare(strict_types=1); namespace app\common\model; -use think\model\concern\SoftDelete; - /** * 企业微信外部联系人模型 */ class QywxExternalContact extends BaseModel { - use SoftDelete; - protected $name = 'qywx_external_contact'; + protected $autoWriteTimestamp = 'datetime'; + protected $createTime = 'created_at'; + protected $updateTime = 'updated_at'; protected $deleteTime = 'delete_time'; - protected $defaultSoftDelete = 0; } diff --git a/server/app/common/model/QywxStaffQrcode.php b/server/app/common/model/QywxStaffQrcode.php new file mode 100644 index 00000000..060b3c69 --- /dev/null +++ b/server/app/common/model/QywxStaffQrcode.php @@ -0,0 +1,51 @@ + 'integer', + 'expire_time' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + // 隐藏字段 + protected $hidden = []; + + // 验证规则 + protected $rule = [ + 'staff_id' => 'require|integer', + 'staff_name' => 'require|max:50', + 'scene' => 'require|max:100|unique:qywx_staff_qrcode', + 'qrcode_url' => 'require|max:255', + 'expire_time' => 'require|datetime', + ]; + + // 错误信息 + protected $message = [ + 'staff_id.require' => '员工ID不能为空', + 'staff_id.integer' => '员工ID必须是整数', + 'staff_name.require' => '员工姓名不能为空', + 'staff_name.max' => '员工姓名不能超过50个字符', + 'scene.require' => '场景值不能为空', + 'scene.max' => '场景值不能超过100个字符', + 'scene.unique' => '场景值已存在', + 'qrcode_url.require' => '二维码URL不能为空', + 'qrcode_url.max' => '二维码URL不能超过255个字符', + 'expire_time.require' => '过期时间不能为空', + 'expire_time.datetime' => '过期时间格式不正确', + ]; +} diff --git a/server/app/common/model/QywxSyncSettings.php b/server/app/common/model/QywxSyncSettings.php index 826d174a..f477e8b0 100644 --- a/server/app/common/model/QywxSyncSettings.php +++ b/server/app/common/model/QywxSyncSettings.php @@ -9,5 +9,5 @@ namespace app\common\model; */ class QywxSyncSettings extends BaseModel { - protected $name = 'qywx_sync_settings'; + protected $table = 'zyt_qywx_sync_settings'; } diff --git a/server/app/common/service/wechat/WechatWorkService.php b/server/app/common/service/wechat/WechatWorkService.php index 7b989847..d1c95275 100644 --- a/server/app/common/service/wechat/WechatWorkService.php +++ b/server/app/common/service/wechat/WechatWorkService.php @@ -15,12 +15,21 @@ class WechatWorkService { private $corpId; private $secret; + private $contactSecret; private $accessToken; + private $contactAccessToken; public function __construct() { $this->corpId = config('pay.wechat_work.corp_id'); $this->secret = config('pay.wechat_work.external_pay_secret'); + $this->contactSecret = config('pay.wechat_work.contact_secret'); + + Log::info('企业微信配置: ' . json_encode([ + 'corp_id' => $this->corpId, + 'secret' => substr($this->secret, 0, 10) . '...', + 'contact_secret' => substr($this->contactSecret, 0, 10) . '...' + ])); } /** @@ -63,12 +72,80 @@ class WechatWorkService throw new \Exception('获取access_token失败: ' . ($response['errmsg'] ?? '未知错误')); } + /** + * @notes 获取客户联系的access_token + * @see https://developer.work.weixin.qq.com/document/path/92113 + */ + private function getContactAccessToken(): string + { + if ($this->contactAccessToken) { + return $this->contactAccessToken; + } + + // 从缓存获取 + $cacheKey = 'qywx_contact_access_token'; + $token = Cache::get($cacheKey); + if ($token) { + $this->contactAccessToken = $token; + return $token; + } + + // 尝试使用 contactSecret 获取 token + $token = $this->requestToken($this->contactSecret, $cacheKey, '客户联系'); + if ($token) { + $this->contactAccessToken = $token; + return $token; + } + + // 如果 contactSecret 失败,尝试使用 secret + Log::info('企业微信-使用 contactSecret 获取 token 失败,尝试使用 secret'); + $token = $this->requestToken($this->secret, $cacheKey, '普通应用'); + if ($token) { + $this->contactAccessToken = $token; + return $token; + } + + throw new \Exception('获取客户联系access_token失败'); + } + + /** + * @notes 请求获取 token + */ + private function requestToken(string $secret, string $cacheKey, string $type): string + { + $url = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken'; + $params = [ + 'corpid' => $this->corpId, + 'corpsecret' => $secret, + ]; + + Log::info('企业微信-获取' . $type . 'AccessToken请求: ' . json_encode($params, JSON_UNESCAPED_UNICODE)); + + $response = $this->httpGet($url, $params); + + Log::info('企业微信-获取' . $type . 'AccessToken响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE)); + + if (isset($response['access_token'])) { + $token = $response['access_token']; + $expiresIn = $response['expires_in'] ?? 7200; + + // 缓存token,提前5分钟过期 + Cache::set($cacheKey, $token, $expiresIn - 300); + + return $token; + } + + Log::error('获取' . $type . 'token失败: ' . json_encode($response)); + return ''; + } + /** * @notes 获取部门成员列表 * @see https://developer.work.weixin.qq.com/document/path/90200 */ public function getDepartmentUserList(int $departmentId = 1, bool $fetchChild = true): array { + // 获取部门成员列表使用 external_pay_secret(普通应用Secret) $accessToken = $this->getAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/user/list'; $params = [ @@ -77,6 +154,8 @@ class WechatWorkService 'fetch_child' => $fetchChild ? 1 : 0, ]; + Log::info('企业微信-获取部门成员列表请求: ' . json_encode($params, JSON_UNESCAPED_UNICODE)); + $response = $this->httpGet($url, $params); Log::info('企业微信-获取部门成员列表响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE)); @@ -93,17 +172,37 @@ class WechatWorkService * @notes 获取成员的客户列表 * @see https://developer.work.weixin.qq.com/document/path/92113 * @param string $userId 成员ID + * @param string $cursor 分页游标,上次返回的 next_cursor + * @param int $limit 最大记录数,最大 100,默认 50 * @return array|false 返回客户列表数组,权限错误返回false */ - public function getExternalContactList(string $userId) + public function getExternalContactList(string $userId, string $cursor = '', int $limit = 50) { - $accessToken = $this->getAccessToken(); + // 生成缓存键 + $cacheKey = 'qywx_external_contact_list_' . $userId . '_' . $cursor . '_' . $limit; + + // 尝试从缓存获取 + $cachedResult = Cache::get($cacheKey); + if ($cachedResult) { + Log::info('从缓存获取客户列表: ' . $userId); + return $cachedResult; + } + + $accessToken = $this->getContactAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/list'; $params = [ 'access_token' => $accessToken, 'userid' => $userId, ]; + // 添加分页参数 + if (!empty($cursor)) { + $params['cursor'] = $cursor; + } + if ($limit > 0) { + $params['limit'] = min($limit, 100); // 最大100 + } + $response = $this->httpGet($url, $params); Log::info('企业微信-获取成员客户列表响应 userId=' . $userId . ': ' . json_encode($response, JSON_UNESCAPED_UNICODE)); @@ -115,25 +214,102 @@ class WechatWorkService } if (isset($response['external_userid'])) { - return $response['external_userid']; + $result = [ + 'external_userid' => $response['external_userid'], + 'next_cursor' => $response['next_cursor'] ?? '' + ]; + + // 缓存结果,有效期5分钟 + Cache::set($cacheKey, $result, 300); + return $result; } // 如果没有客户,返回空数组(不记录错误) if (isset($response['errcode']) && $response['errcode'] == 0) { - return []; + $result = [ + 'external_userid' => [], + 'next_cursor' => '' + ]; + + // 缓存结果,有效期5分钟 + Cache::set($cacheKey, $result, 300); + return $result; } Log::error('获取客户列表失败: ' . json_encode($response)); - return []; + return false; } + + /** + * @notes 获取外部联系人信息 + * @see https://developer.work.weixin.qq.com/document/path/92114 + * @param string $externalUserId 外部用户ID + * @return array|false 返回外部联系人信息,失败返回false + */ + public function getExternalContactInfo(string $externalUserId) + { + $accessToken = $this->getAccessToken(); + $url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get'; + $params = [ + 'access_token' => $accessToken, + 'external_userid' => $externalUserId, + ]; + $response = $this->httpGet($url, $params); + + Log::info('企业微信-获取外部联系人信息响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE)); + + if (isset($response['external_contact'])) { + return $response['external_contact']; + } + + Log::error('获取外部联系人信息失败: ' . json_encode($response)); + return false; + } + + /** + * @notes 生成带参数的二维码 + * @see https://developer.work.weixin.qq.com/document/path/90239 + * @param string $scene 场景值 + * @param int $expireSeconds 过期时间(秒),最大2592000 + * @return array|false 返回二维码信息,失败返回false + */ + public function createQrCode(string $scene, int $expireSeconds = 2592000) + { + $accessToken = $this->getAccessToken(); + $url = "https://qyapi.weixin.qq.com/cgi-bin/qrcode/create?access_token={$accessToken}"; + + $data = [ + "action_info" => [ + "scene" => [ + "scene_str" => $scene + ] + ], + "expire_seconds" => $expireSeconds, + "action_name" => "QR_SCENE" + ]; + + Log::info('企业微信-生成二维码请求: ' . json_encode($data, JSON_UNESCAPED_UNICODE)); + + $response = $this->httpPost($url, json_encode($data)); + + Log::info('企业微信-生成二维码响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE)); + + if (isset($response['ticket'])) { + return $response; + } + + Log::error('生成二维码失败: ' . json_encode($response)); + return false; + } + /** * @notes 获取客户详情 * @see https://developer.work.weixin.qq.com/document/path/92114 */ public function getExternalContactDetail(string $externalUserId): array { - $accessToken = $this->getAccessToken(); + $accessToken = $this->getContactAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get'; $params = [ 'access_token' => $accessToken, @@ -150,6 +326,240 @@ class WechatWorkService return []; } + /** + * @notes 获取所有员工的客户列表 + * @return array 所有员工的客户ID列表,格式:[staff_id => [external_userid1, external_userid2]] + */ + public function getAllStaffCustomers(): array + { + $result = []; + + // 获取所有员工 + $staffList = $this->getDepartmentUserList(); + + if (empty($staffList)) { + Log::info('企业微信-未获取到员工列表'); + return $result; + } + + Log::info('企业微信-开始获取员工客户列表,共 ' . count($staffList) . ' 名员工'); + + foreach ($staffList as $staff) { + $userId = $staff['userid'] ?? ''; + if (empty($userId)) { + continue; + } + + $customerResult = $this->getExternalContactList($userId); + + if ($customerResult === false) { + Log::error('企业微信-员工 ' . $userId . ' 获取客户列表权限不足'); + continue; + } + + $customerIds = $customerResult['external_userid'] ?? []; + + if (!empty($customerIds)) { + $result[$userId] = [ + 'name' => $staff['name'] ?? '', + 'customers' => $customerIds + ]; + Log::info('企业微信-员工 ' . $userId . '(' . ($staff['name'] ?? '') . ') 有 ' . count($customerIds) . ' 个客户'); + } + } + + Log::info('企业微信-共获取到 ' . count($result) . ' 名员工的客户'); + return $result; + } + + /** + * @notes 同步所有客户到本地数据库 + * @return array 同步结果统计 + */ + public function syncAllExternalContacts(): array + { + $stats = [ + 'total_staff' => 0, + 'total_customers' => 0, + 'added' => 0, + 'updated' => 0, + 'errors' => 0 + ]; + + // 获取所有员工的客户 + $allStaffCustomers = $this->getAllStaffCustomers(); + + if (empty($allStaffCustomers)) { + Log::info('企业微信-没有客户需要同步'); + return $stats; + } + + $stats['total_staff'] = count($allStaffCustomers); + + foreach ($allStaffCustomers as $staffId => $staffData) { + $staffName = $staffData['name'] ?? ''; + $customers = $staffData['customers'] ?? []; + + foreach ($customers as $externalUserId) { + $stats['total_customers']++; + + try { + // 获取客户详情 + $customerDetail = $this->getExternalContactDetail($externalUserId); + + if (empty($customerDetail) || !isset($customerDetail['external_contact'])) { + Log::error('企业微信-获取客户详情失败: ' . $externalUserId); + $stats['errors']++; + continue; + } + + $contactInfo = $customerDetail['external_contact']; + + // 更新到数据库 + $result = $this->saveExternalContact($staffId, $staffName, $contactInfo); + + if ($result) { + $stats[$result]++; + } + } catch (\Exception $e) { + Log::error('企业微信-同步客户异常: ' . $e->getMessage()); + $stats['errors']++; + } + } + } + + Log::info('企业微信-同步完成: ' . json_encode($stats)); + return $stats; + } + + /** + * @notes 批量获取成员的客户详情(支持分页) + * @param string $userId 成员ID + * @param string $cursor 分页游标,上次返回的 next_cursor + * @param int $limit 最大记录数,最大 100,默认 50 + * @return array|false 返回客户详情列表,权限错误返回false + */ + public function batchGetCustomerDetails(string $userId, string $cursor = '', int $limit = 50) + { + // 生成缓存键 + $cacheKey = 'qywx_batch_customer_details_' . $userId . '_' . $cursor . '_' . $limit; + + // 尝试从缓存获取 + $cachedResult = Cache::get($cacheKey); + if ($cachedResult) { + Log::info('从缓存获取客户详情: ' . $userId); + return $cachedResult; + } + + // 先获取客户ID列表 + $listResult = $this->getExternalContactList($userId, $cursor, $limit); + if ($listResult === false) { + return false; + } + + $externalUserIds = $listResult['external_userid'] ?? []; + $nextCursor = $listResult['next_cursor'] ?? ''; + + $customers = []; + + // 批量获取客户详情 + foreach ($externalUserIds as $externalUserId) { + $detail = $this->getExternalContactDetail($externalUserId); + if (!empty($detail) && isset($detail['external_contact'])) { + $customers[] = $detail; + } + } + + $result = [ + 'customers' => $customers, + 'next_cursor' => $nextCursor, + 'count' => count($customers) + ]; + + // 缓存结果,有效期5分钟 + Cache::set($cacheKey, $result, 300); + + return $result; + } + + /** + * @notes 保存外部联系人到数据库 + * @param string $staffId 员工ID + * @param string $staffName 员工姓名 + * @param array $contactInfo 联系人信息 + * @return string|false 返回added/updated,失败返回false + */ + private function saveExternalContact(string $staffId, string $staffName, array $contactInfo) + { + $externalUserId = $contactInfo['external_userid'] ?? ''; + if (empty($externalUserId)) { + return false; + } + + // 查询是否已存在 + $model = new \app\common\model\QywxExternalContact(); + $exists = $model->where('external_user_id', $externalUserId)->find(); + + $data = [ + 'external_user_id' => $externalUserId, + 'name' => $contactInfo['name'] ?? '', + 'avatar' => $contactInfo['avatar'] ?? '', + 'type' => $contactInfo['type'] ?? 1, + 'gender' => $contactInfo['gender'] ?? 0, + 'unionid' => $contactInfo['unionid'] ?? '', + 'position' => $contactInfo['position'] ?? '', + 'corp_name' => $contactInfo['corp_name'] ?? '', + 'corp_full_name' => $contactInfo['corp_full_name'] ?? '', + 'external_profile' => json_encode($contactInfo['external_profile'] ?? [], JSON_UNESCAPED_UNICODE), + 'update_time' => time() + ]; + + // 构建跟进人信息 + $followUser = [ + 'userid' => $staffId, + 'name' => $staffName, + 'add_time' => time() + ]; + + if ($exists) { + // 更新已有记录 + $existsData = json_decode($exists['follow_users'] ?? '{"followers":[]}', true); + if (!isset($existsData['followers'])) { + $existsData = ['followers' => []]; + } + + // 检查是否已存在该跟进人 + $found = false; + foreach ($existsData['followers'] as &$follower) { + if ($follower['userid'] === $staffId) { + $follower = $followUser; + $found = true; + break; + } + } + unset($follower); + + if (!$found) { + $existsData['followers'][] = $followUser; + } + + $data['follow_users'] = json_encode($existsData, JSON_UNESCAPED_UNICODE); + $data['update_time'] = time(); + + $exists->save($data); + + return 'updated'; + } else { + // 新增记录 + $data['follow_users'] = json_encode(['followers' => [$followUser]], JSON_UNESCAPED_UNICODE); + $data['create_time'] = time(); + + $model->save($data); + + return 'added'; + } + } + /** * @notes 检查应用权限(用于诊断) * @return array 返回应用的权限信息 @@ -181,7 +591,85 @@ class WechatWorkService ]; } } + + /** + * @notes 创建外部客户(扫码添加) + * @param string $staffId 员工ID + * @param string $staffName 员工姓名 + * @param array $customerInfo 客户信息 + * @return array|false 返回创建结果,失败返回false + */ + public function createExternalContact(string $staffId, string $staffName, array $customerInfo) + { + try { + $accessToken = $this->getContactAccessToken(); + $url = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/add_contact?access_token={$accessToken}"; + + $data = [ + "userid" => $staffId, + "external_contact" => [ + "name" => $customerInfo['name'] ?? '未知客户', + "mobile" => $customerInfo['mobile'] ?? '', + "position" => $customerInfo['position'] ?? '', + "corp_name" => $customerInfo['corp_name'] ?? '', + "avatar" => $customerInfo['avatar'] ?? '' + ] + ]; + + Log::info('企业微信-创建外部客户请求: ' . json_encode($data, JSON_UNESCAPED_UNICODE)); + + $response = $this->httpPost($url, json_encode($data)); + + Log::info('企业微信-创建外部客户响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE)); + + if (isset($response['external_userid'])) { + return $response; + } + + Log::error('创建外部客户失败: ' . json_encode($response)); + return false; + } catch (\Exception $e) { + Log::error('创建外部客户异常: ' . $e->getMessage()); + return false; + } + } + /** + * @notes HTTP POST请求 + */ + private function httpPost(string $url, string $data): array + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $data); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Content-Length: ' . strlen($data) + ]); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + + $output = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + Log::error('HTTP请求失败: ' . $url . ', HTTP Code: ' . $httpCode); + return []; + } + + $result = json_decode($output, true); + if (json_last_error() !== JSON_ERROR_NONE) { + Log::error('JSON解析失败: ' . $output); + return []; + } + + return $result; + } + /** * @notes HTTP GET请求 */ diff --git a/server/config/console.php b/server/config/console.php index 0e803913..dd2b8d18 100644 --- a/server/config/console.php +++ b/server/config/console.php @@ -23,6 +23,6 @@ return [ // 同步现有订单快递单号 'express:sync' => 'app\\command\\SyncTrackingNumbers', // 企业微信客户同步 - 'qywx:sync-customer' => 'app\\command\\QywxSyncCustomer', + 'qywx:sync_customer' => 'app\\common\\command\\QywxSyncCustomer', ], ]; diff --git a/server/config/pay.php b/server/config/pay.php index f057e2c7..635c1160 100644 --- a/server/config/pay.php +++ b/server/config/pay.php @@ -26,9 +26,11 @@ return [ // 企业微信对外收款配置(参考 https://developer.work.weixin.qq.com/document/path/93665) // 获取对外收款记录需使用「对外收款」应用的 secret,在管理后台-应用管理-对外收款-API 中查看 'wechat_work' => [ - 'corp_id' => env('WECHAT_WORK_CORP_ID', '') ?: (string)env('work_wechat.corp_id', ''), + 'corp_id' => env('WORK_WECHAT.CORP_ID', '') ?: env('WECHAT_WORK_CORP_ID', '') ?: (string)env('work_wechat.corp_id', ''), // 对外收款 get_bill_list 必须使用「对外收款」应用的 Secret,在 管理后台-应用管理-对外收款-API 中查看 - 'external_pay_secret' => env('WECHAT_WORK_EXTERNAL_PAY_SECRET', '') ?: (string)env('work_wechat.wechat_work_external_pay_secret', '') ?: (string)env('work_wechat.secret', ''), + 'external_pay_secret' => env('WORK_WECHAT.SECRET', '') ?: env('WECHAT_WORK_EXTERNAL_PAY_SECRET', '') ?: (string)env('work_wechat.wechat_work_external_pay_secret', '') ?: (string)env('work_wechat.secret', ''), + 'agent_id' => env('WORK_WECHAT.AGENT_ID', '') ?: env('WECHAT_WORK_AGENT_ID', ''), + 'contact_secret' => env('WORK_WECHAT.CONTACT_SECRET', '') ?: env('WECHAT_WORK_CONTACT_SECRET', ''), 'notify_url' => env('WECHAT_WORK_NOTIFY_URL', ''), 'mch_id' => env('WECHAT_MCH_ID', ''), 'api_key' => env('WECHAT_API_KEY', ''), diff --git a/server/public/web.config b/server/public/web.config new file mode 100644 index 00000000..eaaeedd4 --- /dev/null +++ b/server/public/web.config @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/server/route/app.php b/server/route/app.php index 8427908b..07e179e4 100644 --- a/server/route/app.php +++ b/server/route/app.php @@ -11,10 +11,45 @@ use think\facade\Console; use think\facade\Route; -// 管理后台 +// 支付回调(免登录,供微信/支付宝/企业微信对外收款调用) +Route::post('api/order/wechat-notify', 'app\adminapi\controller\order\WechatNotifyController@notify'); +Route::post('api/order/alipay-notify', 'app\adminapi\controller\order\AlipayNotifyController@notify'); + +// 腾讯云 TRTC 云端录制回调(免登录,控制台配置回调 URL;驼峰路径与控制台常见填法一致) +Route::post('api/trtc/recording-notify', 'app\api\controller\TrtcController@recordingNotify'); +Route::post('api/trtc/recordingNotify', 'app\api\controller\TrtcController@recordingNotify'); + +// 企业微信回调(免登录) +Route::post('adminapi/qywx.callback/handle', 'app\adminapi\controller\qywx\CallbackController@handle'); + +// 企业微信加粉管理接口 +Route::post('adminapi/qywx.qrcode/create', 'app\adminapi\controller\qywx\QrcodeController@create'); +Route::get('adminapi/qywx.stats/getAddStats', 'app\adminapi\controller\qywx\StatsController@getAddStats'); +Route::get('adminapi/qywx.qrcode/getList', 'app\adminapi\controller\qywx\QrcodeController@getList'); +Route::post('adminapi/qywx.qrcode/delete', 'app\adminapi\controller\qywx\QrcodeController@delete'); +Route::get('adminapi/qywx.qrcode/getStaffList', 'app\adminapi\controller\qywx\QrcodeController@getStaffList'); + +// 企业微信客户管理接口 +Route::get('adminapi/qywx.customer/lists', 'app\adminapi\controller\qywx\CustomerController@lists'); +Route::get('adminapi/qywx.customer/stats', 'app\adminapi\controller\qywx\CustomerController@stats'); +Route::post('adminapi/qywx.customer/import', 'app\adminapi\controller\qywx\CustomerController@import'); +Route::get('adminapi/qywx.customer/getSyncSettings', 'app\adminapi\controller\qywx\CustomerController@getSyncSettings'); +Route::post('adminapi/qywx.customer/saveSyncSettings', 'app\adminapi\controller\qywx\CustomerController@saveSyncSettings'); +Route::post('adminapi/qywx.customer/sync', 'app\adminapi\controller\qywx\CustomerController@sync'); +Route::post('adminapi/qywx.customer/asyncSync', 'app\adminapi\controller\qywx\CustomerController@asyncSync'); +Route::get('adminapi/qywx.customer/getSyncStatus', 'app\adminapi\controller\qywx\CustomerController@getSyncStatus'); +Route::get('adminapi/qywx.customer/getSyncTaskStatus', 'app\adminapi\controller\qywx\CustomerController@getSyncTaskStatus'); +Route::get('adminapi/qywx.customer/getStaffList', 'app\adminapi\controller\qywx\CustomerController@getStaffList'); +Route::get('adminapi/qywx.customer/countByTime', 'app\adminapi\controller\qywx\CustomerController@countByTime'); +Route::get('adminapi/qywx.customer/batchGetCustomers', 'app\adminapi\controller\qywx\CustomerController@batchGetCustomers'); + +// 企业微信扫码回调 +Route::get('qywx/scene/:scene', 'app\adminapi\controller\qywx\ScanController@handleScan'); + +// 管理后台(使用更严格的模式,只匹配具体的管理后台路径,不匹配adminapi) Route::rule('admin/:any', function () { return view(app()->getRootPath() . 'public/admin/index.html'); -})->pattern(['any' => '\w+']); +})->pattern(['any' => '[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)*']); // 手机端 Route::rule('mobile/:any', function () { @@ -29,12 +64,4 @@ Route::rule('pc/:any', function () { //定时任务 Route::rule('crontab', function () { Console::call('crontab'); -}); - -// 支付回调(免登录,供微信/支付宝/企业微信对外收款调用) -Route::post('api/order/wechat-notify', 'app\adminapi\controller\order\WechatNotifyController@notify'); -Route::post('api/order/alipay-notify', 'app\adminapi\controller\order\AlipayNotifyController@notify'); - -// 腾讯云 TRTC 云端录制回调(免登录,控制台配置回调 URL;驼峰路径与控制台常见填法一致) -Route::post('api/trtc/recording-notify', 'app\api\controller\TrtcController@recordingNotify'); -Route::post('api/trtc/recordingNotify', 'app\api\controller\TrtcController@recordingNotify'); +}); \ No newline at end of file diff --git a/sync_qywx.php b/sync_qywx.php new file mode 100644 index 00000000..18a87cc6 --- /dev/null +++ b/sync_qywx.php @@ -0,0 +1,250 @@ + 'running', + 'progress' => 0, + 'start_time' => time(), + 'params' => $params +], 86400); + +try { + $limit = $params['limit'] ?? 30; + $fullSync = $params['full_sync'] ?? false; + $syncMode = $params['sync_mode'] ?? 'all'; + $selectedUsers = $params['selected_users'] ?? []; + $days = $params['days'] ?? 7; + + $service = new \app\common\service\wechat\WechatWorkService(); + + // 1. 获取员工列表 + $userList = $service->getDepartmentUserList(); + if (empty($userList)) { + throw new \Exception('未获取到员工列表'); + } + + // 按同步模式过滤员工 + if ($syncMode === 'specific' && !empty($selectedUsers)) { + $userList = array_filter($userList, function($user) use ($selectedUsers) { + return in_array($user['userid'] ?? '', $selectedUsers); + }); + } + + $syncCount = 0; + $newCount = 0; + $updateCount = 0; + $processedCustomers = []; + + // 获取上次同步时间(使用缓存存储) + $lastSyncCacheKey = 'qywx_last_sync_time'; + $lastSyncTime = Cache::get($lastSyncCacheKey, 0); + $currentTime = time(); + + // 计算时间范围(最近N天) + $timeLimit = $currentTime - ($days * 86400); + + // 总任务量 + $totalTasks = count($userList); + $currentTask = 0; + + // 2. 遍历每个员工,获取其客户列表 + foreach ($userList as $user) { + $userId = $user['userid'] ?? ''; + if (empty($userId)) { + continue; + } + + $currentTask++; + $progress = round(($currentTask / $totalTasks) * 100); + + // 更新任务状态 + Cache::set($cacheKey, [ + 'status' => 'running', + 'progress' => $progress, + 'start_time' => $currentTime, + 'current_user' => $userId, + 'params' => $params + ], 86400); + + // 获取该成员的客户列表(支持分页) + $cursor = ''; + $hasMore = true; + + while ($hasMore && $syncCount < $limit) { + $customerResult = $service->getExternalContactList($userId, $cursor, 100); + if (empty($customerResult) || empty($customerResult['external_userid'])) { + break; + } + + $customerList = $customerResult['external_userid'] ?? []; + $cursor = $customerResult['next_cursor'] ?? ''; + $hasMore = !empty($cursor); + + // 3. 处理客户,限制总数量 + foreach ($customerList as $externalUserId) { + // 达到限制数量,停止同步 + if ($syncCount >= $limit) { + $hasMore = false; + break; + } + + // 避免重复处理同一个客户 + if (isset($processedCustomers[$externalUserId])) { + continue; + } + $processedCustomers[$externalUserId] = true; + + // 增量同步:检查客户是否已存在且更新时间较新 + if (!$fullSync) { + $existing = \app\common\model\QywxExternalContact::where('external_user_id', $externalUserId)->find(); + if ($existing && $existing->update_time && strtotime($existing->update_time) > time() - 86400) { + continue; + } + } + + // 获取客户详情 + $detail = $service->getExternalContactDetail($externalUserId); + if (empty($detail)) { + continue; + } + + $externalContact = $detail['external_contact'] ?? []; + $followUsers = $detail['follow_user'] ?? []; + + // 获取客户实际添加时间 + $createTime = time(); + if (!empty($followUsers)) { + // 取第一个跟进人的添加时间 + $firstFollow = reset($followUsers); + if (isset($firstFollow['create_time'])) { + $createTime = $firstFollow['create_time']; + } + } + + // 增量同步:只同步上次同步时间之后的客户 + if (!$fullSync && $createTime <= $lastSyncTime) { + continue; + } + + // 时间范围过滤:只同步最近N天的客户 + if ($createTime < $timeLimit) { + continue; + } + + // 保存或更新客户信息 + $data = [ + 'external_user_id' => $externalContact['external_userid'] ?? '', + 'name' => $externalContact['name'] ?? '', + 'avatar' => $externalContact['avatar'] ?? '', + 'type' => $externalContact['type'] ?? 1, + 'gender' => $externalContact['gender'] ?? 0, + 'unionid' => $externalContact['unionid'] ?? '', + 'position' => $externalContact['position'] ?? '', + 'corp_name' => $externalContact['corp_name'] ?? '', + 'corp_full_name' => $externalContact['corp_full_name'] ?? '', + 'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE), + 'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE), + ]; + + // 处理添加人信息 + if (!empty($followUsers)) { + // 取第一个跟进人作为添加人 + $firstFollow = reset($followUsers); + if (isset($firstFollow['userid']) && isset($firstFollow['name'])) { + $data['first_staff_id'] = $firstFollow['userid']; + $data['first_staff_name'] = $firstFollow['name']; + $data['first_add_time'] = date('Y-m-d H:i:s', $createTime); + $data['last_staff_id'] = $firstFollow['userid']; + $data['last_staff_name'] = $firstFollow['name']; + $data['last_add_time'] = date('Y-m-d H:i:s', $createTime); + } + } + + $existing = \app\common\model\QywxExternalContact::where('external_user_id', $data['external_user_id'])->find(); + if ($existing) { + $existing->save($data); + $updateCount++; + } else { + \app\common\model\QywxExternalContact::create($data); + $newCount++; + } + + $syncCount++; + } + } + + // 达到限制数量,停止同步 + if ($syncCount >= $limit) { + break; + } + } + + // 保存本次同步时间(使用缓存存储) + Cache::set($lastSyncCacheKey, $currentTime, 86400 * 30); // 缓存30天 + + // 更新任务状态 + Cache::set($cacheKey, [ + 'status' => 'completed', + 'progress' => 100, + 'start_time' => $currentTime, + 'end_time' => time(), + 'sync_count' => $syncCount, + 'new_count' => $newCount, + 'update_count' => $updateCount, + 'params' => $params + ], 86400); + + echo "Sync completed: {$syncCount} records processed\n"; + exit(0); + +} catch (\Throwable $e) { + // 更新任务状态为失败 + Cache::set($cacheKey, [ + 'status' => 'failed', + 'progress' => 0, + 'error' => $e->getMessage(), + 'params' => $params + ], 86400); + + echo "Sync failed: {$e->getMessage()}\n"; + exit(1); +} \ No newline at end of file