Files
zyt/server/app/common/service/qywx/QywxPromotionMediaService.php
T
2026-08-31 15:17:34 +08:00

304 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
use think\file\UploadedFile;
/** 私有源文件 + 可刷新三天临时素材。欢迎语关键路径仅使用缓存,不下载/上传文件。 */
class QywxPromotionMediaService
{
private QywxPromotionContactApiService $api;
private QywxPromotionMediaStore $store;
private string $root;
public function __construct(?QywxPromotionContactApiService $api = null, ?QywxPromotionMediaStore $store = null, ?string $root = null)
{
$this->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;
}
}