api = $api ?? new QywxPromotionContactApiService(); $this->store = $store ?? new QywxPromotionMediaStore(); // runtime_path()在adminapi/api/CLI间不同;使用项目级私有目录保证上传与worker共享。 $this->root = rtrim($root ?? (root_path('runtime') . 'qywx_promotion_private' . DIRECTORY_SEPARATOR . 'media'), '/\\'); } /** @return array{asset_id:string,name:string,type:string} */ public function upload($file, string $type, int $adminId): array { if ($adminId <= 0 || !$file instanceof UploadedFile || !$file->isValid()) { throw new RuntimeException('请上传有效文件'); } if (!in_array($type, ['image', 'video', 'file'], true)) { throw new RuntimeException('素材类型仅支持 image、video、file'); } $size = (int) $file->getSize(); $limit = ($type === 'file' ? 20 : 10) * 1024 * 1024; if ($size <= 5 || $size > $limit) { throw new RuntimeException($type === 'file' ? '文件须大于5字节且不超过20MB' : '图片/视频须大于5字节且不超过10MB'); } $mime = (new \finfo(FILEINFO_MIME_TYPE))->file($file->getPathname()); $name = str_replace('\\', '/', $file->getOriginalName()); $name = mb_substr(preg_replace('/[\x00-\x1f\x7f]/u', '', basename($name)) ?? '', 0, 180); $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if ($type === 'image') { $info = @getimagesize($file->getPathname()); if (!in_array($mime, ['image/jpeg', 'image/png'], true) || $info === false || !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG], true)) { throw new RuntimeException('图片仅支持真实 JPG/PNG 文件'); } $extension = $mime === 'image/png' ? 'png' : 'jpg'; } elseif ($type === 'video') { if ($mime !== 'video/mp4' || $extension !== 'mp4') { throw new RuntimeException('视频仅支持 MP4'); } } else { // 私有存储也拒绝可执行内容/HTML/SVG;按实际 MIME 与扩展名双重检查。 $allowed = [ 'pdf' => ['application/pdf'], 'txt' => ['text/plain'], 'csv' => ['text/plain', 'text/csv', 'application/csv'], 'doc' => ['application/msword', 'application/x-ole-storage', 'application/CDFV2'], 'xls' => ['application/vnd.ms-excel', 'application/x-ole-storage', 'application/CDFV2'], 'ppt' => ['application/vnd.ms-powerpoint', 'application/x-ole-storage', 'application/CDFV2'], 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip'], 'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip'], 'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/zip'], 'zip' => ['application/zip'], 'jpg' => ['image/jpeg'], 'jpeg' => ['image/jpeg'], 'png' => ['image/png'], 'mp4' => ['video/mp4'], ]; if (!isset($allowed[$extension]) || !in_array($mime, $allowed[$extension], true)) { throw new RuntimeException('不支持该文件格式,请上传PDF、Office、文本、ZIP、JPG/PNG或MP4'); } } if ($name === '') { $name = '素材.' . $extension; } $this->ensureRoot(); $assetId = bin2hex(random_bytes(24)); $storageName = $assetId . '.' . $extension; $hash = hash_file('sha256', $file->getPathname()); $file->move($this->root, $storageName); @chmod($this->root . DIRECTORY_SEPARATOR . $storageName, 0600); try { $this->store->insert([ 'asset_id' => $assetId, 'admin_id' => $adminId, 'name' => $name, 'type' => $type, 'mime' => $mime, 'size' => $size, 'sha256' => $hash, 'storage_name' => $storageName, 'media_id' => '', 'media_expires_at' => 0, 'credential_hash' => '', 'last_error' => '', 'create_time' => time(), 'update_time' => time(), ]); } catch (\Throwable $e) { @unlink($this->root . DIRECTORY_SEPARATOR . $storageName); throw new RuntimeException('素材入库失败,请确认已安装推广自动化数据表', 0, $e); } // 配置阶段就上传企微素材。失败保留私有文件供后续排障,不对外提供文件路径。 $this->mediaId($assetId, $type, true); return ['asset_id' => $assetId, 'name' => $name, 'type' => $type]; } /** 旧方案授权由上层完成;只白名单旧配置实际已有资产,不接受请求单独声明的白名单。 */ public function validateConfig(array $config, int $adminId, array $existingConfig = []): array { $allowed = self::assetIds($existingConfig); $config['welcome']['attachments'] = $this->validateAttachments((array) ($config['welcome']['attachments'] ?? []), $adminId, $allowed); foreach ((array) ($config['welcome_schedule'] ?? []) as $index => $slot) { $config['welcome_schedule'][$index]['attachments'] = $this->validateAttachments((array) ($slot['attachments'] ?? []), $adminId, $allowed); } return $config; } public function validateAttachments(array $attachments, int $adminId, array $allowedAssetIds = []): array { if (count($attachments) > 9) { throw new RuntimeException('欢迎语最多9个附件'); } $clean = []; foreach ($attachments as $attachment) { if (!is_array($attachment)) { throw new RuntimeException('附件格式不正确'); } $type = (string) ($attachment['msgtype'] ?? ''); $body = $attachment[$type] ?? null; if (!is_array($body)) { throw new RuntimeException('附件内容类型不匹配'); } if (in_array($type, ['image', 'video', 'file'], true)) { // image.pic_url 限企微 uploadimg URL;本服务仅接受私有资产,避免伪装任意外部地址。 $asset = $this->authorizedAsset((string) ($body['asset_id'] ?? ''), $type, $adminId, $allowedAssetIds); $body = ['asset_id' => $asset['asset_id']]; } elseif ($type === 'link') { $body = [ 'title' => self::bytes($body['title'] ?? '', 128, '链接标题', true), 'url' => self::url($body['url'] ?? ''), 'desc' => self::bytes($body['desc'] ?? '', 512, '链接描述'), ] + (!empty($body['picurl']) ? ['picurl' => self::url($body['picurl'])] : []); } elseif ($type === 'miniprogram') { $asset = $this->authorizedAsset((string) ($body['pic_asset_id'] ?? ''), 'image', $adminId, $allowedAssetIds); $appid = (string) ($body['appid'] ?? ''); $page = self::bytes($body['page'] ?? '', 1024, '小程序页面', true); if (!preg_match('/^wx[0-9a-fA-F]{16}$/', $appid) || str_contains($page, '://') || str_contains($page, '..') || preg_match('/[\x00-\x1f]/', $page)) { throw new RuntimeException('小程序 appid 或页面路径不正确'); } $body = ['title' => self::bytes($body['title'] ?? '', 64, '小程序标题', true), 'appid' => $appid, 'page' => $page, 'pic_asset_id' => $asset['asset_id']]; } else { throw new RuntimeException('不支持的附件类型'); } $clean[] = ['msgtype' => $type, $type => $body]; } return $clean; } /** 仅处理已授权并持久化的配置快照;绝不在欢迎语发送时进行网络文件上传。 */ public function materialize(array $attachments, array $config): array { $attachments = $this->validateAttachments($attachments, 0, self::assetIds($config)); foreach ($attachments as &$attachment) { $type = $attachment['msgtype']; if (in_array($type, ['image', 'video', 'file'], true)) { $attachment[$type] = ['media_id' => $this->mediaId($attachment[$type]['asset_id'], $type, false)]; } elseif ($type === 'miniprogram') { $attachment[$type]['pic_media_id'] = $this->mediaId($attachment[$type]['pic_asset_id'], 'image', false); unset($attachment[$type]['pic_asset_id']); } } unset($attachment); return $attachments; } public function refreshReferenced(int $limit = 100): array { $result = ['selected' => 0, 'refreshed' => 0, 'failed' => 0]; foreach ($this->store->referencedAssetIds() as $id) { $asset = $this->store->find($id); if (!$asset || ($this->cacheValid($asset, 3600))) { continue; } if ($result['selected'] >= max(1, $limit)) { break; } ++$result['selected']; try { $this->mediaId($id, $asset['type'], true, 3600); ++$result['refreshed']; } catch (\Throwable) { ++$result['failed']; } } return $result; } public static function assetIds(array $config): array { $ids = []; $messages = array_merge([(array) ($config['welcome'] ?? [])], (array) ($config['welcome_schedule'] ?? [])); foreach ($messages as $message) { foreach ((array) ($message['attachments'] ?? []) as $attachment) { $type = $attachment['msgtype'] ?? ''; $key = $type === 'miniprogram' ? 'pic_asset_id' : 'asset_id'; $id = (string) ($attachment[$type][$key] ?? ''); if (preg_match('/^[0-9a-f]{48}$/', $id)) { $ids[] = $id; } } } return array_values(array_unique($ids)); } private function authorizedAsset(string $id, string $type, int $adminId, array $allowed): array { if (!preg_match('/^[0-9a-f]{48}$/', $id)) { throw new RuntimeException('请先上传欢迎语素材'); } $asset = $this->store->find($id); if (!$asset || $asset['type'] !== $type || ((int) $asset['admin_id'] !== $adminId && !in_array($id, $allowed, true))) { throw new RuntimeException('素材不存在、类型不匹配或无权使用'); } return $asset; } private function mediaId(string $id, string $type, bool $allowUpload, int $margin = 300): string { $asset = $this->store->find($id); if (!$asset || $asset['type'] !== $type) { throw new RuntimeException('欢迎语素材不存在'); } if ($this->cacheValid($asset, $margin)) { return (string) $asset['media_id']; } if (!$allowUpload) { throw new RuntimeException('欢迎语素材未预热或已过期,请检查素材刷新任务'); } $stream = null; try { $path = $this->privatePath((string) $asset['storage_name']); if (!is_file($path) || hash_file('sha256', $path) !== $asset['sha256']) { throw new RuntimeException('欢迎语源文件缺失或完整性检查失败'); } $stream = fopen($path, 'rb'); $result = $this->api->uploadMedia($stream, $type, (string) $asset['name']); if (empty($result['media_id'])) { throw new RuntimeException('企微素材接口未返回 media_id'); } $created = min(time(), (int) ($result['created_at'] ?? time())); $this->store->update($id, ['media_id' => (string) $result['media_id'], 'media_expires_at' => $created + 3 * 86400, 'credential_hash' => $this->api->credentialFingerprint(), 'last_error' => '', 'update_time' => time()]); return (string) $result['media_id']; } catch (\Throwable $e) { $this->store->update($id, ['last_error' => '素材预热失败[' . (int) $e->getCode() . ']', 'update_time' => time()]); throw $e; } finally { if (is_resource($stream)) { fclose($stream); } } } private function cacheValid(array $asset, int $margin): bool { return !empty($asset['media_id']) && (int) $asset['media_expires_at'] > time() + $margin && hash_equals((string) $asset['credential_hash'], $this->api->credentialFingerprint()); } private function privatePath(string $name): string { if (!preg_match('/^[0-9a-f]{48}\.[a-z0-9]{1,8}$/', $name)) { throw new RuntimeException('素材存储标识不正确'); } $root = realpath($this->root); $path = realpath($this->root . DIRECTORY_SEPARATOR . $name); if ($root === false || $path === false || !str_starts_with($path, $root . DIRECTORY_SEPARATOR)) { throw new RuntimeException('素材文件不在私有存储目录'); } return $path; } private function ensureRoot(): void { if (!is_dir($this->root) && !mkdir($this->root, 0700, true) && !is_dir($this->root)) { throw new RuntimeException('无法创建私有素材目录'); } } private static function bytes(mixed $value, int $limit, string $label, bool $required = false): string { if (!is_string($value) || strlen($value) > $limit || ($required && trim($value) === '')) { throw new RuntimeException($label . '须' . ($required ? '非空且' : '') . '不超过' . $limit . '字节'); } return trim($value); } private static function url(mixed $value): string { if (!is_string($value) || strlen($value) > 2048 || filter_var($value, FILTER_VALIDATE_URL) === false) { throw new RuntimeException('链接地址不正确'); } $parts = parse_url($value); if (!in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true) || isset($parts['user']) || isset($parts['pass'])) { throw new RuntimeException('链接仅支持不含账号密码的HTTP(S)地址'); } // 仅向企微传递链接;服务端永远不会抓取这些URL。 return $value; } }