'充电底座', 100 => 'K霸宝', 101 => '充电宝', 102 => 'K霸宝 Blue', 103 => '密码线充', 104 => '蓝牙线充', 105 => '密码蓝牙线充', 107 => '车载充电器', ]; private const CATEGORY_TYPES = [ 'vehicle' => [107], 'power-bank' => [1, 100, 101, 102, 103, 104, 105], ]; public function __construct() { $this->model = new MerchantDevice(); } /** * 当前代理管理的设备分页列表。 */ public function indexList(array $where): array { $page = max(1, (int) request()->input('page', 1)); $limit = min(100, max(1, (int) request()->input('limit', 20))); $result = $this->buildListQuery($where) ->order('d.id', 'desc') ->paginate([ 'page' => $page, 'list_rows' => $limit, ]) ->toArray(); $result['data'] = array_map(fn (array $row) => $this->formatDevice($row), $result['data'] ?? []); $result['summary'] = $this->summary($where); return $result; } /** * 当前账号下级代理管理的设备列表。 * * 下级范围由 sa_system_dept 的 level 路径确定;设备仍以 * sa_device.manage_agent_id 作为当前管理关系,避免把设备所有权 * (belong_agent_id) 误当作可操作权限。 */ public function subordinateIndexList(array $where): array { $page = max(1, (int) request()->input('page', 1)); $limit = min(100, max(1, (int) request()->input('limit', 20))); $result = $this->buildListQuery($where, true) ->order('d.id', 'desc') ->paginate([ 'page' => $page, 'list_rows' => $limit, ]) ->toArray(); $result['data'] = array_map(fn (array $row) => $this->formatDevice($row), $result['data'] ?? []); $result['summary'] = $this->summary($where, true); return $result; } /** * 按当前搜索条件导出全部设备。 */ public function export(array $where): Response { $rows = $this->buildListQuery($where)->order('d.id', 'desc')->select()->toArray(); $data = []; foreach ($rows as $row) { $device = $this->formatDevice($row); $data[] = [ 'id' => $device['id'], 'name' => $this->excelSafe($device['name']), 'type_name' => $device['type_name'], 'device_sn' => $this->excelSafe($device['device_sn']), 'qr_code' => $this->excelSafe($device['qr_code']), 'model' => $this->excelSafe($device['version']), 'status_name' => $device['status_name'], 'signal' => $device['signal'], 'heartbeat' => $device['heartbeat'], 'distributed' => $device['distributed'] ? '已绑定' : '未绑定', 'merchant' => $this->excelSafe($device['merchant']), 'merchant_phone' => $this->excelSafe($device['merchant_phone']), 'manage_agent_id' => $device['manage_agent_id'], 'is_active' => $device['is_active'] ? '已激活' : '未激活', 'active_time' => $device['active_time'], 'distributed_at' => $device['distributed_at'], 'created_at' => $device['created_at'], ]; } $fileName = '我的设备_' . date('Ymd_His') . '.xlsx'; $writer = new OpenSpoutWriter($fileName); $writer->setWidth([10, 20, 18, 24, 22, 16, 12, 12, 12, 12, 22, 16, 14, 12, 20, 20, 20]); $writer->setHeader([ '设备ID', '设备名称', '设备类型', '设备SN', '二维码/机器码', '设备型号', '运营状态', '设备信号', '心跳状态', '绑定状态', '商户名称', '商户电话', '管理代理ID', '激活状态', '激活时间', '绑定时间', '创建时间', ]); $writer->setData($data); return response()->download($writer->returnFile(), urlencode($fileName)); } /** * 返回设备归属、当前管理者、商户绑定及二维码内容。 */ public function readDevice(int $deviceId, bool $subordinate = false): array { $device = $this->findAccessibleDevice($deviceId, $subordinate); $binding = Db::table('sa_merchant_device_binding')->alias('b') ->leftJoin('sa_merchant m', 'm.id = b.merchant_id AND m.delete_time IS NULL') ->where('b.device_id', $deviceId) ->field('b.merchant_id,b.bind_time,m.name AS merchant_name,m.phone AS merchant_phone,m.address AS merchant_address,m.agent_id') ->find(); $owner = $this->userSummary((int) ($device['belong_agent_id'] ?? 0)); $manager = $this->userSummary((int) ($device['manage_agent_id'] ?? 0)); $merchantOperator = $this->userSummary((int) ($binding['agent_id'] ?? 0)); $code = (string) (($device['device_code'] ?? '') ?: ($device['goods_sn'] ?? '')); $qrBaseUrl = rtrim(trim((string) env('DEVICE_QR_BASE_URL', '')), '/'); return [ 'device' => array_merge($this->formatDevice($device), [ 'qr_content' => $qrBaseUrl !== '' ? $qrBaseUrl . '/' . rawurlencode($code) : $code, 'can_switch_power' => (int) ($device['goods_type'] ?? 0) === 107, 'can_unbind' => !empty($binding), ]), 'owner' => $owner, 'manager' => $manager, 'merchant' => empty($binding) ? null : [ 'id' => (int) $binding['merchant_id'], 'name' => (string) ($binding['merchant_name'] ?? ''), 'phone' => (string) ($binding['merchant_phone'] ?? ''), 'address' => (string) ($binding['merchant_address'] ?? ''), 'bind_time' => (string) ($binding['bind_time'] ?? ''), 'operator' => $merchantOperator, ], ]; } /** * 沿用旧系统车辆控制协议:POST /cdy/tasks,task_type=switch,value=1/0。 * 指令服务必须显式配置,避免在未接入硬件网关时伪造成功状态。 */ public function switchPower(int $deviceId, string $action, bool $subordinate = false): array { $device = $this->findAccessibleDevice($deviceId, $subordinate); if ((int) ($device['goods_type'] ?? 0) !== 107) { throw new ApiException('仅车载充电器支持供电和断电操作'); } $endpoint = trim((string) env('DEVICE_COMMAND_ENDPOINT', '')); if ($endpoint === '') { throw new ApiException('设备指令服务未配置,无法向真实设备下发操作'); } $value = $action === 'on' ? 1 : 0; $payload = [ 'uid' => (string) (($device['goods_sn'] ?? '') ?: ($device['device_code'] ?? '')), 'task_type' => 'switch', 'value' => $value, ]; $response = $this->postDeviceCommand($endpoint, $payload); $success = in_array((int) ($response['code'] ?? -1), [0, 200], true); if ($success && $value === 1 && isset($response['data']['queued']) && (int) $response['data']['queued'] !== 1) { $success = false; } $this->writeCommandLog($deviceId, $action, $payload, $response, $success); if (!$success) { throw new ApiException((string) ($response['msg'] ?? '设备指令下发失败')); } return [ 'device_id' => $deviceId, 'action' => $action, 'queued' => $response['data']['queued'] ?? true, ]; } /** * 从“我的设备”解除当前商户绑定,复用旧系统解绑语义并写入流水。 */ public function unbindDevice(int $deviceId, bool $subordinate = false): bool { $device = $this->findAccessibleDevice($deviceId, $subordinate); $operatorId = $this->currentAdminId(); return (bool) Db::transaction(function () use ($device, $deviceId, $operatorId) { $binding = Db::table('sa_merchant_device_binding')->where('device_id', $deviceId)->lock(true)->find(); if (empty($binding)) { throw new ApiException('该设备当前未铺货,无需解绑'); } $now = date('Y-m-d H:i:s'); Db::table('sa_merchant_device_binding')->where('device_id', $deviceId)->delete(); Db::table('sa_merchant_device_binding_record')->insert([ 'device_id' => $deviceId, 'device_sn' => (string) $binding['device_sn'], 'depend_type' => (int) $binding['depend_type'], 'merchant_id' => (int) $binding['merchant_id'], 'action_type' => 2, 'operator_id' => $operatorId, 'action_time' => $now, 'created_by' => $operatorId, 'create_time' => $now, ]); Db::table('sa_device')->where('id', $deviceId)->update([ 'is_active' => 0, 'active_time' => null, 'updated_by' => $operatorId, 'update_time' => $now, ]); $merchantId = (int) $binding['merchant_id']; $count = Db::table('sa_merchant_device_binding')->where('merchant_id', $merchantId)->count(); Db::table('sa_merchant')->where('id', $merchantId)->update([ 'device_count' => $count, 'distributed' => $count > 0 ? 1 : 0, 'update_time' => $now, 'updated_by' => $operatorId, ]); return true; }); } private function buildListQuery(array $where, bool $subordinate = false) { $query = Db::table('sa_device')->alias('d') ->leftJoin('sa_merchant_device_binding b', 'b.device_id = d.id') ->leftJoin('sa_merchant m', 'm.id = b.merchant_id AND m.delete_time IS NULL') ->leftJoin('sa_system_user owner', 'owner.id = d.belong_agent_id AND owner.delete_time IS NULL') ->leftJoin('sa_system_user manager', 'manager.id = d.manage_agent_id AND manager.delete_time IS NULL') ->whereNull('d.delete_time') ->field(implode(',', [ 'd.id', 'd.goods_name', 'd.goods_type', 'd.goods_sn', 'd.device_code', 'd.model', 'd.status', 'd.belong_agent_id', 'd.manage_agent_id', 'd.is_locked', 'd.is_active', 'd.active_time', 'd.device_status', 'd.create_time', 'd.update_time', 'b.merchant_id', 'b.bind_time', 'm.name AS merchant_name', 'm.phone AS merchant_phone', 'owner.id AS owner_id', 'owner.username AS owner_username', 'owner.realname AS owner_realname', 'owner.phone AS owner_phone', 'manager.id AS manager_id', 'manager.username AS manager_username', 'manager.realname AS manager_realname', 'manager.phone AS manager_phone', ])); if ($subordinate) { $managerIds = $this->subordinateManagerIds(); if ($managerIds === []) { $query->where('d.id', 0); } else { $query->whereIn('d.manage_agent_id', $managerIds); } } else { $currentId = $this->currentAdminId(); if ($currentId !== 1) { $query->where('d.manage_agent_id', $currentId); } } $category = trim((string) ($where['category'] ?? '')); if ($category !== '' && $category !== 'all' && isset(self::CATEGORY_TYPES[$category])) { $query->whereIn('d.goods_type', self::CATEGORY_TYPES[$category]); } if (($where['goods_type'] ?? '') !== '') { $query->where('d.goods_type', (int) $where['goods_type']); } $merchant = trim((string) ($where['merchant'] ?? '')); if ($merchant !== '') { $query->whereLike('m.name', '%' . $merchant . '%'); } $qrCode = trim((string) ($where['qr_code'] ?? '')); if ($qrCode !== '') { $query->whereLike('d.device_code', '%' . $qrCode . '%'); } $deviceSn = trim((string) ($where['device_sn'] ?? '')); if ($deviceSn !== '') { $query->whereLike('d.goods_sn', '%' . $deviceSn . '%'); } $signal = trim((string) ($where['signal'] ?? '')); if ($signal === 'online') { $query->where('d.device_status', 1); } elseif ($signal === 'offline') { $query->where('d.device_status', '<>', 1); } $deviceState = trim((string) ($where['device_state'] ?? '')); if ($deviceState === 'online') { $query->where('d.device_status', 1); } elseif ($deviceState === 'offline') { $query->where('d.device_status', '<>', 1); } elseif ($deviceState === 'bound') { $query->whereNotNull('b.device_id'); } elseif ($deviceState === 'unbound') { $query->whereNull('b.device_id'); } return $query; } private function findAccessibleDevice(int $deviceId, bool $subordinate = false): array { if ($deviceId <= 0) { throw new ApiException('请选择设备'); } $query = Db::table('sa_device')->where('id', $deviceId)->whereNull('delete_time'); if ($subordinate) { $managerIds = $this->subordinateManagerIds(); if ($managerIds === []) { throw new ApiException('设备不存在或无权操作'); } $query->whereIn('manage_agent_id', $managerIds); } elseif ($this->currentAdminId() !== 1) { $query->where('manage_agent_id', $this->currentAdminId()); } $device = $query->find(); if (empty($device)) { throw new ApiException('设备不存在或无权操作'); } return $device; } private function userSummary(int $userId): ?array { if ($userId <= 0) { return null; } $user = SystemUser::where('id', $userId)->field('id,username,realname,phone')->findOrEmpty(); if ($user->isEmpty()) { return ['id' => $userId, 'name' => '未找到账号', 'username' => '', 'phone' => '']; } return [ 'id' => (int) $user->id, 'name' => (string) ($user->realname ?: $user->username), 'username' => (string) $user->username, 'phone' => (string) ($user->phone ?? ''), ]; } private function postDeviceCommand(string $endpoint, array $payload): array { if (!function_exists('curl_init')) { throw new ApiException('当前 PHP 未启用 cURL,无法调用设备指令服务'); } $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); $curl = curl_init($endpoint); curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json'], ]); $raw = curl_exec($curl); $error = curl_error($curl); $statusCode = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); curl_close($curl); if ($raw === false) { throw new ApiException('设备指令服务请求失败:' . ($error ?: '连接异常')); } $decoded = json_decode($raw, true); if (!is_array($decoded)) { throw new ApiException('设备指令服务返回了无效响应(HTTP ' . $statusCode . ')'); } if ($statusCode < 200 || $statusCode >= 300) { $decoded['code'] = $decoded['code'] ?? $statusCode; $decoded['msg'] = $decoded['msg'] ?? '设备指令服务请求失败'; } return $decoded; } private function writeCommandLog(int $deviceId, string $action, array $request, array $response, bool $success): void { Db::table('sa_device_command_log')->insert([ 'device_id' => $deviceId, 'device_sn' => (string) ($request['uid'] ?? ''), 'command' => 'switch', 'action' => $action, 'request_payload' => json_encode($request, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'response_payload' => json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'status' => $success ? 1 : 2, 'operator_id' => $this->currentAdminId(), 'create_time' => date('Y-m-d H:i:s'), ]); } private function summary(array $where, bool $subordinate = false): array { $categoryWhere = $where; $categoryWhere['category'] = 'all'; $statusWhere = $where; $statusWhere['device_state'] = ''; return [ 'all' => (int) $this->buildListQuery($categoryWhere, $subordinate)->count(), 'vehicle' => (int) $this->buildListQuery(array_merge($categoryWhere, ['category' => 'vehicle']), $subordinate)->count(), 'power-bank' => (int) $this->buildListQuery(array_merge($categoryWhere, ['category' => 'power-bank']), $subordinate)->count(), 'online' => (int) $this->buildListQuery(array_merge($statusWhere, ['device_state' => 'online']), $subordinate)->count(), 'offline' => (int) $this->buildListQuery(array_merge($statusWhere, ['device_state' => 'offline']), $subordinate)->count(), 'bound' => (int) $this->buildListQuery(array_merge($statusWhere, ['device_state' => 'bound']), $subordinate)->count(), 'unbound' => (int) $this->buildListQuery(array_merge($statusWhere, ['device_state' => 'unbound']), $subordinate)->count(), ]; } private function formatDevice(array $row): array { $type = (int) ($row['goods_type'] ?? 0); $deviceStatus = (int) ($row['device_status'] ?? 0); $merchantId = (int) ($row['merchant_id'] ?? 0); $qrCode = (string) (($row['device_code'] ?? '') ?: ($row['goods_sn'] ?? '')); $qrBaseUrl = rtrim(trim((string) env('DEVICE_QR_BASE_URL', '')), '/'); $device = [ 'id' => (int) $row['id'], 'category' => $type === 107 ? 'vehicle' : 'power-bank', 'name' => (string) ($row['goods_name'] ?? ''), 'goods_type' => $type, 'type_name' => self::TYPE_NAMES[$type] ?? '其他设备', 'qr_code' => $qrCode, 'qr_content' => $qrBaseUrl !== '' ? $qrBaseUrl . '/' . rawurlencode($qrCode) : $qrCode, 'device_sn' => (string) ($row['goods_sn'] ?? ''), 'borrowable' => '--', 'imei' => '--', 'version' => (string) (($row['model'] ?? '') ?: '--'), 'signal' => $deviceStatus === 1 ? '在线' : '离线', 'signal_value' => $deviceStatus === 1 ? '正常' : '无信号', 'connection_count' => 0, 'distributed' => $merchantId > 0, 'distributed_at' => (string) ($row['bind_time'] ?? ''), 'merchant_id' => $merchantId, 'merchant' => (string) (($row['merchant_name'] ?? '') ?: '--'), 'merchant_phone' => (string) ($row['merchant_phone'] ?? ''), 'wechat_orders' => 0, 'alipay_orders' => 0, 'amount' => '0.00', 'heartbeat' => $this->heartbeatName($deviceStatus), 'status' => (int) ($row['status'] ?? 0), 'status_name' => $this->statusName((int) ($row['status'] ?? 0)), 'manage_agent_id' => (int) ($row['manage_agent_id'] ?? 0), 'is_locked' => (int) ($row['is_locked'] ?? 0) === 1, 'is_active' => (int) ($row['is_active'] ?? 0) === 1, 'active_time' => (string) ($row['active_time'] ?? ''), 'created_at' => (string) ($row['create_time'] ?? ''), ]; if (array_key_exists('owner_id', $row)) { $device['owner'] = $this->personFromRow($row, 'owner'); $device['manager'] = $this->personFromRow($row, 'manager'); } return $device; } private function statusName(int $status): string { return match ($status) { 1 => '运营中', 2 => '报修', default => '未启用', }; } private function heartbeatName(int $status): string { return match ($status) { 1 => '正常', 2 => '维护', 3 => '异常', default => '掉线', }; } private function currentAdminId(): int { return (int) ($this->adminInfo['id'] ?? 0); } /** @return int[] */ private function subordinateManagerIds(): array { $currentId = $this->currentAdminId(); if ($currentId <= 0) { return []; } if ($currentId === 1) { return array_map('intval', SystemUser::where('id', '<>', 1) ->whereNull('delete_time')->column('id')); } $dept = $this->adminInfo['deptList'] ?? []; if (empty($dept['id']) || !isset($dept['level'])) { return []; } $prefix = (string) $dept['level'] . (int) $dept['id'] . ','; $deptIds = Db::table('sa_system_dept')->whereNull('delete_time') ->whereLike('level', $prefix . '%')->column('id'); if ($deptIds === []) { return []; } return array_map('intval', SystemUser::whereIn('dept_id', $deptIds) ->whereNull('delete_time')->column('id')); } private function personFromRow(array $row, string $prefix): ?array { $id = (int) ($row[$prefix . '_id'] ?? 0); if ($id <= 0) { return null; } $username = (string) ($row[$prefix . '_username'] ?? ''); $realname = (string) ($row[$prefix . '_realname'] ?? ''); return [ 'id' => $id, 'name' => $realname !== '' ? $realname : ($username !== '' ? $username : '未找到账号'), 'username' => $username, 'phone' => (string) ($row[$prefix . '_phone'] ?? ''), ]; } private function excelSafe(mixed $value): string { $value = (string) $value; return preg_match('/^[=+\-@]/u', $value) === 1 ? "'" . $value : $value; } }