mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-29 14:01:07 +00:00
827 lines
34 KiB
PHP
827 lines
34 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
|
||
// +----------------------------------------------------------------------
|
||
// | Author: CRMEB Team <admin@crmeb.com>
|
||
// +----------------------------------------------------------------------
|
||
declare (strict_types=1);
|
||
|
||
namespace app\services\system\attachment;
|
||
|
||
use crmeb\basic\BaseService;
|
||
use app\dao\system\attachment\SystemAttachmentDao;
|
||
use app\services\product\product\CopyProductServices;
|
||
use crmeb\exceptions\AdminException;
|
||
use crmeb\exceptions\ApiException;
|
||
use crmeb\exceptions\UploadException;
|
||
use app\services\driver\upload\UploadService;
|
||
use crmeb\services\CacheService;
|
||
use think\facade\Config;
|
||
|
||
/**
|
||
*
|
||
* Class SystemAttachmentServices
|
||
* @package app\services\attachment
|
||
* @method getYesterday() 获取昨日生成数据
|
||
* @method delYesterday() 删除昨日生成数据
|
||
*/
|
||
class SystemAttachmentServices extends BaseService
|
||
{
|
||
/** 扫码上传能力的有效期,单位秒 */
|
||
private const SCAN_UPLOAD_TTL = 600;
|
||
|
||
/** 扫码能力摘要缓存前缀 */
|
||
private const SCAN_UPLOAD_TOKEN_KEY = 'attachment:scan_upload:token:';
|
||
|
||
/** 管理员当前扫码能力缓存前缀 */
|
||
private const SCAN_UPLOAD_ADMIN_KEY = 'attachment:scan_upload:admin:';
|
||
|
||
/**
|
||
* @var SystemAttachmentDao
|
||
*/
|
||
protected $dao;
|
||
|
||
/**
|
||
* SystemAttachmentServices constructor.
|
||
* @param SystemAttachmentDao $dao
|
||
*/
|
||
public function __construct(SystemAttachmentDao $dao)
|
||
{
|
||
$this->dao = $dao;
|
||
}
|
||
|
||
/**
|
||
* 获取单个资源
|
||
* @param array $where
|
||
* @param string $field
|
||
* @return array
|
||
* @throws \think\db\exception\DataNotFoundException
|
||
* @throws \think\db\exception\DbException
|
||
* @throws \think\db\exception\ModelNotFoundException
|
||
*/
|
||
public function getInfo(array $where, string $field = '*')
|
||
{
|
||
return $this->dao->getOne($where, $field);
|
||
}
|
||
|
||
/**
|
||
* 获取图片列表
|
||
* @param array $where
|
||
* @return array
|
||
*/
|
||
public function getImageList(array $where)
|
||
{
|
||
[$page, $limit, $defaultLimit] = $this->getPageValue();
|
||
$page = max(1, $page);
|
||
$limit = $limit > 0 ? $limit : max(1, $defaultLimit);
|
||
$limit = min($limit, max(1, (int)Config::get('database.page.limitMax', 50)));
|
||
$pid = $where['pid'] ?? 0;
|
||
// 管理端分类树用空字符串表示“全部”,列表查询统一按不限制分类的 0 处理。
|
||
$where['pid'] = $this->parseCategoryId($pid === '' ? 0 : $pid);
|
||
$where['type'] = $this->parseCategoryId($where['type'] ?? 0);
|
||
if (!in_array($where['type'], [0, 1], true) || !is_string($where['real_name'] ?? '')) {
|
||
throw new AdminException('附件查询参数错误');
|
||
}
|
||
$where['module_type'] = 1;
|
||
$list = $this->dao->getList($where, $page, $limit);
|
||
$siteUrl = rtrim((string)sys_config('site_url'), '/');
|
||
foreach ($list as &$item) {
|
||
foreach (['att_dir', 'satt_dir'] as $field) {
|
||
$path = (string)($item[$field] ?? '');
|
||
if ($siteUrl !== '' && $path !== '' && !preg_match('~^(?:https?:)?//~i', $path)) {
|
||
$path = $siteUrl . '/' . ltrim($path, '/');
|
||
}
|
||
$item[$field] = $path;
|
||
}
|
||
$item['time'] = date('Y-m-d H:i:s', (int)$item['time']);
|
||
}
|
||
unset($item);
|
||
$count = $this->dao->count($where);
|
||
return compact('list', 'count');
|
||
}
|
||
|
||
/**
|
||
* 删除图片
|
||
* @param string $ids
|
||
* @return void
|
||
* @throws \Throwable
|
||
*/
|
||
public function del(string $ids)
|
||
{
|
||
$ids = $this->parseAttachmentIds($ids);
|
||
$attachments = $this->getManagedAttachments($ids, true);
|
||
// 先确认整批素材归属,文件删除成功后才移除对应记录,失败项可安全重试。
|
||
foreach ($attachments as $attachment) {
|
||
$this->deleteAttachmentFile($attachment);
|
||
if ($this->dao->delete(['att_id' => (int)$attachment['att_id'], 'module_type' => 1]) === false) {
|
||
throw new \Exception(getLang('删除附件记录失败'));
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 图片上传
|
||
* @param int $pid
|
||
* @param string $file
|
||
* @param int $upload_type
|
||
* @param int $type
|
||
* @param string $menuName
|
||
* @param string $uploadToken 扫码上传能力摘要,普通上传为空
|
||
* @return mixed
|
||
*/
|
||
public function upload(int $pid, string $file, int $upload_type, int $type, $menuName, $uploadToken = '')
|
||
{
|
||
$this->checkCategory($this->parseCategoryId($pid), $type);
|
||
$realName = false;
|
||
if ($menuName == 'weixin_ckeck_file' || $menuName == 'ico_path') {
|
||
$upload_type = 1;
|
||
$realName = true;
|
||
}
|
||
|
||
if ($upload_type == 0) {
|
||
// 从 eb_driver 表获取当前启用的驱动(status=1),不再使用旧的 sys_config('upload_type')
|
||
/** @var \app\services\driver\upload\UploadDriverServices $driverServices */
|
||
$driverServices = app()->make(\app\services\driver\upload\UploadDriverServices::class);
|
||
$enabledDrivers = $driverServices->getEnabledDrivers();
|
||
$driverKey = !empty($enabledDrivers) ? $enabledDrivers[0]['driver_key'] : 'local';
|
||
$upload = UploadService::init($driverKey);
|
||
$upload_type = $driverServices->getTypeByDriverKey($driverKey);
|
||
} else {
|
||
$upload = UploadService::init($upload_type);
|
||
}
|
||
try {
|
||
$path = make_path('attach', 2, true);
|
||
if ($path === '') {
|
||
throw new AdminException('无法创建文件夹,请检查您的上传目录权限');
|
||
}
|
||
$res = $upload->to($path)->validate()->move($file, $realName);
|
||
if ($res === false) {
|
||
throw new UploadException($upload->getError());
|
||
} else {
|
||
$fileInfo = $upload->getUploadInfo();
|
||
$fileType = strtolower(pathinfo($fileInfo['name'] ?? '', PATHINFO_EXTENSION));
|
||
if ($fileInfo && $type == 0 && in_array($fileType, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'])) {
|
||
$data['name'] = $fileInfo['name'];
|
||
$data['real_name'] = $fileInfo['real_name'];
|
||
$data['att_dir'] = $fileInfo['dir'];
|
||
$data['satt_dir'] = $fileInfo['thumb_path'];
|
||
$data['att_size'] = $fileInfo['size'];
|
||
$data['att_type'] = $fileInfo['type'];
|
||
$data['image_type'] = $upload_type;
|
||
$data['module_type'] = 1;
|
||
$data['time'] = $fileInfo['time'] ?? time();
|
||
$data['pid'] = $pid;
|
||
$data['scan_token'] = $uploadToken;
|
||
if (!$this->dao->save($data)) {
|
||
throw new UploadException('保存附件记录失败');
|
||
}
|
||
}
|
||
return $res->filePath;
|
||
}
|
||
} catch (UploadException $e) {
|
||
throw $e;
|
||
} catch (\Exception $e) {
|
||
throw new UploadException($e->getMessage(), [], 0, $e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 为当前管理员创建绑定图片分类的临时扫码能力
|
||
* @param int|string $adminId
|
||
* @param int|string $pid
|
||
* @return array
|
||
*/
|
||
public function scanUploadQrcode($adminId, $pid = 0): array
|
||
{
|
||
$adminId = $this->parseScanUploadAdminId($adminId);
|
||
$pid = $this->parseCategoryId($pid);
|
||
$this->checkCategory($pid, 0);
|
||
return CacheService::lock('attachment:scan_upload:lock:' . $adminId, function () use ($adminId, $pid): array {
|
||
$this->clearScanUploadSession($adminId);
|
||
$token = bin2hex(random_bytes(32));
|
||
$digest = hash('sha256', $token);
|
||
$tokenKey = self::SCAN_UPLOAD_TOKEN_KEY . $digest;
|
||
$adminKey = self::SCAN_UPLOAD_ADMIN_KEY . $adminId;
|
||
$session = ['token_hash' => $digest, 'admin_id' => $adminId, 'pid' => $pid, 'expires_at' => time() + self::SCAN_UPLOAD_TTL];
|
||
try {
|
||
// 两个索引均写入并读回确认后,才向调用方签发扫码链接。
|
||
if (CacheService::set($tokenKey, $session, self::SCAN_UPLOAD_TTL) !== true
|
||
|| CacheService::set($adminKey, $digest, self::SCAN_UPLOAD_TTL) !== true
|
||
|| $this->getScanUploadSession($token, $adminId) !== $session) {
|
||
throw new \Exception(getLang('创建扫码上传链接失败'));
|
||
}
|
||
} catch (\Throwable $e) {
|
||
CacheService::delete($adminKey);
|
||
CacheService::delete($tokenKey);
|
||
throw new \Exception(getLang('创建扫码上传链接失败'), 0, $e);
|
||
}
|
||
$url = rtrim((string)sys_config('site_url'), '/') . '/app/upload?' . http_build_query(['pid' => $pid, 'token' => $token]);
|
||
return ['url' => $url];
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 校验扫码能力和绑定分类后,执行公共图片上传
|
||
* @param int|string $pid
|
||
* @param string $file
|
||
* @param mixed $uploadToken
|
||
* @param int $uploadType
|
||
* @param int $type
|
||
* @return mixed
|
||
*/
|
||
public function scanUpload($pid, $file, $uploadToken, int $uploadType = 0, int $type = 0)
|
||
{
|
||
$session = $this->getScanUploadSession($uploadToken);
|
||
$pid = $this->parseCategoryId($pid);
|
||
if ($session['pid'] !== $pid || $type !== 0) {
|
||
throw new AdminException('配置已更改或token已失效');
|
||
}
|
||
if (!is_string($file) || $file === '') {
|
||
throw new AdminException('附件上传参数错误');
|
||
}
|
||
return $this->upload($pid, $file, $uploadType, 0, '', $session['token_hash']);
|
||
}
|
||
|
||
/**
|
||
* 获取当前管理员拥有的有效扫码能力对应图片
|
||
* @param mixed $scanToken
|
||
* @param int|string $adminId
|
||
* @return array
|
||
*/
|
||
public function scanUploadImage($scanToken, $adminId): array
|
||
{
|
||
$session = $this->getScanUploadSession($scanToken, $this->parseScanUploadAdminId($adminId));
|
||
return $this->dao->scanUploadImage($session['token_hash']);
|
||
}
|
||
|
||
/**
|
||
* 删除当前管理员的扫码能力,不接受调用方指定其他能力
|
||
* @param int|string $adminId
|
||
* @return void
|
||
*/
|
||
public function removeUploadQrcode($adminId): void
|
||
{
|
||
$adminId = $this->parseScanUploadAdminId($adminId);
|
||
CacheService::lock('attachment:scan_upload:lock:' . $adminId, function () use ($adminId): void {
|
||
$this->clearScanUploadSession($adminId);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 校验管理员身份,不允许缺失或宽松数值转换
|
||
* @param int|string $adminId
|
||
* @return int
|
||
*/
|
||
protected function parseScanUploadAdminId($adminId): int
|
||
{
|
||
$adminId = $this->parseCategoryId($adminId);
|
||
if ($adminId === 0) {
|
||
throw new AdminException('管理员身份无效');
|
||
}
|
||
return $adminId;
|
||
}
|
||
|
||
/**
|
||
* 校验扫码能力或摘要的严格编码
|
||
* @param mixed $value
|
||
* @return bool
|
||
*/
|
||
protected function isScanUploadHash($value): bool
|
||
{
|
||
return is_string($value) && preg_match('/^[a-f0-9]{64}$/D', $value) === 1;
|
||
}
|
||
|
||
/**
|
||
* 校验完整扫码缓存和管理员当前索引,缓存缺失或损坏均拒绝
|
||
* @param mixed $token
|
||
* @param int|null $adminId 管理端轮询时必须提供当前身份
|
||
* @return array
|
||
*/
|
||
protected function getScanUploadSession($token, ?int $adminId = null): array
|
||
{
|
||
if (!$this->isScanUploadHash($token)) {
|
||
throw new AdminException('配置已更改或token已失效');
|
||
}
|
||
$digest = hash('sha256', $token);
|
||
$session = CacheService::get(self::SCAN_UPLOAD_TOKEN_KEY . $digest, null);
|
||
$now = time();
|
||
if (!is_array($session) || count($session) !== 4
|
||
|| !$this->isScanUploadHash($session['token_hash'] ?? null)
|
||
|| !hash_equals($session['token_hash'], $digest)
|
||
|| !is_int($session['admin_id'] ?? null) || $session['admin_id'] < 1
|
||
|| !is_int($session['pid'] ?? null) || $session['pid'] < 0
|
||
|| !is_int($session['expires_at'] ?? null) || $session['expires_at'] <= $now
|
||
|| $session['expires_at'] > $now + self::SCAN_UPLOAD_TTL
|
||
|| ($adminId !== null && $session['admin_id'] !== $adminId)) {
|
||
throw new AdminException('配置已更改或token已失效');
|
||
}
|
||
$currentDigest = CacheService::get(self::SCAN_UPLOAD_ADMIN_KEY . $session['admin_id'], null);
|
||
if (!$this->isScanUploadHash($currentDigest) || !hash_equals($currentDigest, $digest)) {
|
||
throw new AdminException('配置已更改或token已失效');
|
||
}
|
||
return $session;
|
||
}
|
||
|
||
/**
|
||
* 撤销自己的当前索引,只删除归属验证通过的能力缓存
|
||
* @param int $adminId
|
||
* @return void
|
||
*/
|
||
protected function clearScanUploadSession(int $adminId): void
|
||
{
|
||
$adminKey = self::SCAN_UPLOAD_ADMIN_KEY . $adminId;
|
||
$digest = CacheService::get($adminKey, null);
|
||
if ($digest === null) {
|
||
return;
|
||
}
|
||
$tokenKey = null;
|
||
if ($this->isScanUploadHash($digest)) {
|
||
$session = CacheService::get(self::SCAN_UPLOAD_TOKEN_KEY . $digest, null);
|
||
if (is_array($session) && ($session['admin_id'] ?? null) === $adminId
|
||
&& $this->isScanUploadHash($session['token_hash'] ?? null)
|
||
&& hash_equals($session['token_hash'], $digest)) {
|
||
$tokenKey = self::SCAN_UPLOAD_TOKEN_KEY . $digest;
|
||
}
|
||
}
|
||
// 先撤销当前索引,即使后续过期记录清理失败也无法继续使用扫码能力。
|
||
if (CacheService::delete($adminKey) === false && CacheService::get($adminKey, null) !== null) {
|
||
throw new \Exception(getLang('删除扫码上传链接失败'));
|
||
}
|
||
if ($tokenKey !== null && CacheService::delete($tokenKey) === false && CacheService::get($tokenKey, null) !== null) {
|
||
throw new \Exception(getLang('删除扫码上传链接失败'));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 移动素材分类
|
||
* @param array $data
|
||
* @return mixed
|
||
*/
|
||
public function move(array $data)
|
||
{
|
||
$ids = $this->parseAttachmentIds($data['images'] ?? '');
|
||
$pid = $this->parseCategoryId($data['pid'] ?? 0);
|
||
$attachments = $this->getManagedAttachments($ids);
|
||
$types = array_unique(array_map(static fn(array $attachment): int => (int)$attachment['type'], $attachments));
|
||
foreach ($types as $type) {
|
||
$this->checkCategory($pid, $type);
|
||
}
|
||
$result = $this->dao->move(['images' => $ids, 'pid' => $pid]);
|
||
if ($result === false) {
|
||
throw new \Exception(getLang('移动附件失败'));
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 修改素材显示名称,不修改存储对象的实际键
|
||
* @param int|string $id
|
||
* @param array $data
|
||
* @return mixed
|
||
*/
|
||
public function update($id, array $data)
|
||
{
|
||
$ids = $this->parseAttachmentIds([$id]);
|
||
$this->getManagedAttachments($ids);
|
||
$realName = $data['real_name'] ?? '';
|
||
if (!is_string($realName) || trim($realName) === '' || mb_strlen($realName) > 255) {
|
||
throw new AdminException('文件名称不能为空且不能超过255个字符');
|
||
}
|
||
$result = $this->dao->update(['att_id' => $ids[0], 'module_type' => 1], ['real_name' => trim($realName)]);
|
||
if (!$result) {
|
||
throw new \Exception(getLang('修改附件名称失败'));
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 校验素材ID并去重
|
||
* @param string|array $ids
|
||
* @return int[]
|
||
*/
|
||
protected function parseAttachmentIds($ids): array
|
||
{
|
||
$values = is_string($ids) ? explode(',', $ids) : $ids;
|
||
if (!is_array($values) || !$values || count($values) > 1000) {
|
||
throw new AdminException('请选择有效的附件');
|
||
}
|
||
$result = [];
|
||
foreach ($values as $value) {
|
||
if ((!is_int($value) && !is_string($value)) || !preg_match('/^[1-9][0-9]*$/D', (string)$value)) {
|
||
throw new AdminException('附件ID错误');
|
||
}
|
||
$id = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||
if ($id === false) {
|
||
throw new AdminException('附件ID错误');
|
||
}
|
||
$result[$id] = $id;
|
||
}
|
||
return array_values($result);
|
||
}
|
||
|
||
/**
|
||
* 校验非负分类ID
|
||
* @param int|string $id
|
||
* @return int
|
||
*/
|
||
protected function parseCategoryId($id): int
|
||
{
|
||
if ((!is_int($id) && !is_string($id)) || !preg_match('/^(?:0|[1-9][0-9]*)$/D', (string)$id)) {
|
||
throw new AdminException('附件分类参数错误');
|
||
}
|
||
$value = filter_var($id, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
|
||
if ($value === false) {
|
||
throw new AdminException('附件分类参数错误');
|
||
}
|
||
return $value;
|
||
}
|
||
|
||
/**
|
||
* 批量查询可由素材管理操作的附件
|
||
* @param int[] $ids
|
||
* @param bool $allowMissing 删除时允许已经不存在的记录
|
||
* @return array
|
||
*/
|
||
protected function getManagedAttachments(array $ids, bool $allowMissing = false): array
|
||
{
|
||
$attachments = $this->dao->selectList([
|
||
['att_id', 'in', $ids],
|
||
], 'att_id,name,att_dir,image_type,module_type,type')->toArray();
|
||
foreach ($attachments as $attachment) {
|
||
if ((int)$attachment['module_type'] !== 1) {
|
||
throw new AdminException('附件不存在或不可操作');
|
||
}
|
||
}
|
||
if (!$allowMissing && count($attachments) !== count($ids)) {
|
||
throw new AdminException('附件不存在或不可操作');
|
||
}
|
||
return $attachments;
|
||
}
|
||
|
||
/**
|
||
* 确认分类存在且与素材类型一致,零表示未分类
|
||
* @param int $pid
|
||
* @param int $type
|
||
* @return void
|
||
*/
|
||
protected function checkCategory(int $pid, int $type): void
|
||
{
|
||
if ($pid === 0) {
|
||
return;
|
||
}
|
||
$category = app()->make(SystemAttachmentCategoryServices::class)->get($pid, ['id', 'type']);
|
||
if (!$category || (int)$category['type'] !== $type) {
|
||
throw new AdminException('附件分类不存在或类型不匹配');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除附件对应的真实文件,不猜测当前启用的云空间
|
||
* @param array $attachment
|
||
* @return void
|
||
* @throws \Throwable
|
||
*/
|
||
protected function deleteAttachmentFile(array $attachment): void
|
||
{
|
||
$isLocal = (int)$attachment['image_type'] === 1;
|
||
$key = (string)($isLocal ? $attachment['att_dir'] : $attachment['name']);
|
||
if ($key === '' || (!$isLocal && empty($attachment['att_dir']))) {
|
||
throw new UploadException('附件文件路径为空,无法安全删除');
|
||
}
|
||
$upload = $isLocal ? UploadService::init('local') : UploadService::getOssInit((string)$attachment['att_dir']);
|
||
if ($upload->delete($key) === false) {
|
||
throw new UploadException($upload->getError() ?: '删除附件文件失败');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 添加信息
|
||
* @param array $data
|
||
* @return void
|
||
*/
|
||
public function save(array $data)
|
||
{
|
||
$data['real_name'] = $data['real_name'] ?? basename((string)($data['name'] ?? ''));
|
||
if (!$this->dao->save($data)) {
|
||
throw new UploadException('保存附件记录失败');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* TODO 添加附件记录
|
||
* @param $name
|
||
* @param $att_size
|
||
* @param $att_type
|
||
* @param $att_dir
|
||
* @param string $satt_dir
|
||
* @param int $pid
|
||
* @param int $imageType
|
||
* @param int $time
|
||
* @return SystemAttachment
|
||
*/
|
||
public function attachmentAdd($name, $att_size, $att_type, $att_dir, $satt_dir = '', $pid = 0, $imageType = 1, $time = 0, $module_type = 1, $type = 0, $real_name = '')
|
||
{
|
||
$data['name'] = $name;
|
||
$data['att_dir'] = $att_dir;
|
||
$data['satt_dir'] = $satt_dir;
|
||
$data['att_size'] = $att_size;
|
||
$data['att_type'] = $att_type;
|
||
$data['image_type'] = $imageType;
|
||
$data['module_type'] = $module_type;
|
||
$data['time'] = $time ?: time();
|
||
$data['pid'] = $pid;
|
||
$data['type'] = $type;
|
||
$data['real_name'] = $real_name !== '' ? $real_name : basename((string)$name);
|
||
if (!$this->dao->save($data)) {
|
||
throw new ApiException('添加失败');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 按逻辑名称前缀获取用户生成海报,非分页最多1000条
|
||
* @param string $name
|
||
* @return array
|
||
*/
|
||
public function getLikeNameList($name)
|
||
{
|
||
return $this->dao->getLikeNameList(['like_real_name' => $name], 0, 0);
|
||
}
|
||
|
||
/**
|
||
* 清除昨日海报
|
||
* @return bool
|
||
* @throws \Exception
|
||
*/
|
||
public function emptyYesterdayAttachment(): bool
|
||
{
|
||
$list = $this->dao->getYesterday();
|
||
foreach ($list as $item) {
|
||
$attachment = is_array($item) ? $item : $item->toArray();
|
||
$this->deleteAttachmentFile($attachment);
|
||
if ($this->dao->delete(['att_id' => (int)$attachment['att_id'], 'module_type' => 2]) === false) {
|
||
throw new \Exception(getLang('删除附件记录失败'));
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 分批清理全部用户生成的临时附件,保留未成功删除的记录
|
||
* @return bool
|
||
* @throws \Throwable
|
||
*/
|
||
public function clearTemporaryAttachments(): bool
|
||
{
|
||
$afterId = 0;
|
||
do {
|
||
$attachments = $this->dao->selectList([
|
||
['module_type', '=', 2], ['att_id', '>', $afterId],
|
||
], 'att_id,name,att_dir,image_type,module_type', 1, 100, 'att_id ASC')->toArray();
|
||
foreach ($attachments as $attachment) {
|
||
$this->deleteAttachmentFile($attachment);
|
||
if ($this->dao->delete(['att_id' => (int)$attachment['att_id'], 'module_type' => 2]) === false) {
|
||
throw new \Exception(getLang('删除附件记录失败'));
|
||
}
|
||
$afterId = (int)$attachment['att_id'];
|
||
}
|
||
} while (count($attachments) === 100);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 视频分片上传
|
||
* @param $data
|
||
* @param $file
|
||
* @return mixed
|
||
*/
|
||
public function videoUpload($data, $file)
|
||
{
|
||
$originalName = $data['filename'] ?? '';
|
||
if (!is_string($originalName) || $originalName === '' || strlen($originalName) > 255
|
||
|| preg_match('~[/\\\\\x00-\x1f\x7f]~', $originalName)) {
|
||
throw new AdminException('视频文件名称错误');
|
||
}
|
||
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||
if (!in_array($extension, ['avi', 'mp4', 'wmv', 'rm', 'mpg', 'mpeg', 'mov', 'flv'], true)) {
|
||
throw new AdminException('格式错误');
|
||
}
|
||
$md5 = $data['md5'] ?? '';
|
||
if (!is_string($md5) || !preg_match('/^[a-f0-9]{32}$/iD', $md5)) {
|
||
throw new AdminException('视频文件校验参数错误');
|
||
}
|
||
$md5 = strtolower($md5);
|
||
$chunkNumber = $this->parseCategoryId($data['chunkNumber'] ?? 0);
|
||
$totalChunks = $this->parseCategoryId($data['totalChunks'] ?? 0);
|
||
$chunkSize = $this->parseCategoryId($data['chunkSize'] ?? 0);
|
||
$currentChunkSize = $this->parseCategoryId($data['currentChunkSize'] ?? 0);
|
||
$maxSize = max(1, (int)Config::get('upload.filesize', 52428800));
|
||
if ($chunkNumber < 1 || $totalChunks < 1 || $chunkNumber > $totalChunks || $totalChunks > 10000
|
||
|| $chunkSize < 1 || $chunkSize > $maxSize || $currentChunkSize < 1 || $currentChunkSize > $chunkSize
|
||
|| ($totalChunks - 1) > intdiv($maxSize - 1, $chunkSize)
|
||
|| ($chunkNumber < $totalChunks && $currentChunkSize !== $chunkSize)) {
|
||
throw new AdminException('视频分片参数错误或文件超过大小限制');
|
||
}
|
||
if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK
|
||
|| empty($file['tmp_name']) || filesize($file['tmp_name']) !== $currentChunkSize) {
|
||
throw new UploadException('视频分片上传失败或大小不匹配');
|
||
}
|
||
$public_dir = app()->getRootPath() . 'public';
|
||
$dir = '/uploads/attach/' . date('Y/m/d');
|
||
$all_dir = $public_dir . $dir;
|
||
if (!is_dir($all_dir) && !mkdir($all_dir, 0755, true) && !is_dir($all_dir)) {
|
||
throw new \Exception(getLang('无法创建视频上传目录'));
|
||
}
|
||
// 使用内容摘要隔离同名视频分片,最终对象名不受用户原始名称影响。
|
||
$filename = $all_dir . '/' . $md5 . '.' . $extension;
|
||
$chunkPrefix = $filename . '__';
|
||
if (!move_uploaded_file($file['tmp_name'], $chunkPrefix . $chunkNumber)) {
|
||
throw new UploadException('保存视频分片失败');
|
||
}
|
||
$res = ['code' => 1, 'msg' => 'waiting', 'file_path' => ''];
|
||
if ($chunkNumber === $totalChunks) {
|
||
$blob = '';
|
||
for ($i = 1; $i <= $totalChunks; $i++) {
|
||
$chunkFile = $chunkPrefix . $i;
|
||
if (!file_exists($chunkFile)) {
|
||
throw new UploadException('视频分片不完整,请重新上传');
|
||
}
|
||
$size = filesize($chunkFile);
|
||
if ($size === false || $size < 1 || $size > $chunkSize
|
||
|| ($i < $totalChunks && $size !== $chunkSize) || $size > $maxSize - strlen($blob)) {
|
||
throw new UploadException('视频分片大小错误或文件超过大小限制');
|
||
}
|
||
$chunk = file_get_contents($chunkFile);
|
||
if ($chunk === false || strlen($chunk) !== $size) {
|
||
throw new UploadException('读取视频分片失败');
|
||
}
|
||
$blob .= $chunk;
|
||
}
|
||
if (md5($blob) !== $md5) {
|
||
throw new UploadException('视频文件校验失败,请重新上传');
|
||
}
|
||
$pendingFile = $filename . '.part';
|
||
if (file_put_contents($pendingFile, $blob, LOCK_EX) !== strlen($blob)) {
|
||
throw new UploadException('合并视频分片失败');
|
||
}
|
||
$mime = mime_content_type($pendingFile);
|
||
if (!in_array($mime, ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/avi', 'video/x-ms-wmv',
|
||
'video/mpeg', 'video/x-flv', 'application/vnd.rn-realmedia', 'application/vnd.rn-realmedia-vbr'], true)) {
|
||
if (!unlink($pendingFile)) {
|
||
throw new \Exception(getLang('清理非法视频文件失败'));
|
||
}
|
||
throw new UploadException('视频文件内容格式错误');
|
||
}
|
||
if (!rename($pendingFile, $filename)) {
|
||
throw new UploadException('保存视频文件失败');
|
||
}
|
||
for ($i = 1; $i <= $totalChunks; $i++) {
|
||
if (!unlink($chunkPrefix . $i)) {
|
||
throw new \Exception(getLang('清理视频分片失败'));
|
||
}
|
||
}
|
||
$res = ['code' => 2, 'msg' => 'success', 'file_path' => rtrim((string)sys_config('site_url'), '/') . $dir . '/' . $md5 . '.' . $extension];
|
||
}
|
||
return $res;
|
||
}
|
||
|
||
/**
|
||
* 保存SDK或本地分片上传的视频,名称与类型取自实际存储路径
|
||
* @param array $data
|
||
* @return bool
|
||
*/
|
||
public function videoDataSave(array $data): bool
|
||
{
|
||
$pid = $this->parseCategoryId($data['pid'] ?? 0);
|
||
$this->checkCategory($pid, 1);
|
||
$realName = $data['video_name'] ?? '';
|
||
$path = $data['video_path'] ?? '';
|
||
if (!is_string($realName) || trim($realName) === '' || mb_strlen($realName) > 255
|
||
|| !is_string($path) || $path === '') {
|
||
throw new AdminException('视频名称或路径错误');
|
||
}
|
||
$upload = UploadService::getOssInit($path);
|
||
$storage = $upload->getStorageInfo();
|
||
$driverKey = $storage['driver_key'] ?? '';
|
||
$urlPath = parse_url($path, PHP_URL_PATH);
|
||
if (!is_string($urlPath) || $urlPath === '') {
|
||
throw new AdminException('视频文件路径错误');
|
||
}
|
||
$key = $driverKey === 'local' ? basename(rawurldecode($urlPath)) : $this->getStorageObjectKey($path, $storage);
|
||
$extension = strtolower(pathinfo($key, PATHINFO_EXTENSION));
|
||
$mimeTypes = ['mp4' => 'video/mp4', 'avi' => 'video/x-msvideo', 'wmv' => 'video/x-ms-wmv',
|
||
'rm' => 'application/vnd.rn-realmedia', 'mpg' => 'video/mpeg', 'mpeg' => 'video/mpeg',
|
||
'mov' => 'video/quicktime', 'flv' => 'video/x-flv'];
|
||
if (!isset($mimeTypes[$extension])) {
|
||
throw new AdminException('视频文件格式错误');
|
||
}
|
||
$imageType = $driverKey === 'local' ? 1 : app()->make(\app\services\driver\upload\UploadDriverServices::class)->getTypeByDriverKey($driverKey);
|
||
$this->save([
|
||
'name' => $key, 'real_name' => trim($realName), 'att_dir' => $path, 'satt_dir' => $path,
|
||
'att_size' => 0, 'att_type' => $mimeTypes[$extension], 'pid' => $pid, 'image_type' => $imageType,
|
||
'module_type' => 1, 'type' => 1, 'time' => time(),
|
||
]);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 从已匹配存储域名或CDN前缀中提取真实云对象键
|
||
* @param string $path
|
||
* @param array $storage
|
||
* @return string
|
||
*/
|
||
protected function getStorageObjectKey(string $path, array $storage): string
|
||
{
|
||
$url = parse_url(str_starts_with($path, '//') ? 'https:' . $path : $path);
|
||
$matchedKey = null;
|
||
$matchedPrefixLength = -1;
|
||
foreach (['domain', 'cdn'] as $field) {
|
||
$base = parse_url((string)($storage[$field] ?? ''));
|
||
if (!$url || !$base || empty($url['host']) || empty($base['host'])
|
||
|| strtolower($url['host']) !== strtolower($base['host'])
|
||
|| strtolower($url['scheme'] ?? '') !== strtolower($base['scheme'] ?? '')
|
||
|| ($url['port'] ?? (strtolower($url['scheme'] ?? '') === 'https' ? 443 : 80))
|
||
!== ($base['port'] ?? (strtolower($base['scheme'] ?? '') === 'https' ? 443 : 80))) {
|
||
continue;
|
||
}
|
||
$urlPath = rawurldecode($url['path'] ?? '');
|
||
$prefix = rtrim(rawurldecode($base['path'] ?? ''), '/');
|
||
if ($prefix !== '' && !str_starts_with($urlPath, $prefix . '/')) {
|
||
continue;
|
||
}
|
||
$key = ltrim(substr($urlPath, strlen($prefix)), '/');
|
||
if ($key !== '' && !preg_match('~[\\\\\x00-\x1f\x7f]~', $key)
|
||
&& !array_intersect(['.', '..', ''], explode('/', $key))
|
||
&& strlen($prefix) > $matchedPrefixLength) {
|
||
$matchedKey = $key;
|
||
$matchedPrefixLength = strlen($prefix);
|
||
}
|
||
}
|
||
if ($matchedKey !== null) {
|
||
return $matchedKey;
|
||
}
|
||
throw new UploadException('无法从原存储空间安全识别视频对象');
|
||
}
|
||
|
||
/**
|
||
* 网络图片上传
|
||
* @param $data
|
||
* @return bool
|
||
* @throws \Exception
|
||
* @author 吴汐
|
||
* @email 442384644@qq.com
|
||
* @date 2023/06/13
|
||
*/
|
||
public function onlineUpload($data)
|
||
{
|
||
$pid = $this->parseCategoryId($data['pid'] ?? 0);
|
||
$this->checkCategory($pid, 0);
|
||
$images = $data['images'] ?? [];
|
||
if (!is_array($images) || !$images || count($images) > 100) {
|
||
throw new AdminException('请选择有效的网络图片');
|
||
}
|
||
foreach ($images as $image) {
|
||
if (!is_string($image) || $image === '') {
|
||
throw new AdminException('网络图片地址错误');
|
||
}
|
||
}
|
||
//生成附件目录
|
||
if (make_path('attach', 3, true) === '') {
|
||
throw new AdminException('无法创建文件夹,请检查您的上传目录权限');
|
||
}
|
||
|
||
//上传图片
|
||
$siteUrl = rtrim((string)sys_config('site_url'), '/');
|
||
|
||
foreach ($images as $image) {
|
||
$uploadValue = app()->make(CopyProductServices::class)->downloadImage($image);
|
||
if (!is_array($uploadValue) || empty($uploadValue['path'])) {
|
||
throw new UploadException('网络图片上传失败');
|
||
}
|
||
$imagePath = $uploadValue['path'];
|
||
if ((int)$uploadValue['image_type'] === 1 && !preg_match('~^(?:https?:)?//~i', $imagePath)) {
|
||
$imagePath = $siteUrl . '/' . ltrim($imagePath, '/');
|
||
}
|
||
if (!$uploadValue['is_exists']) {
|
||
$this->save([
|
||
'name' => $uploadValue['name'],
|
||
'real_name' => $uploadValue['real_name'] ?? basename($uploadValue['name']),
|
||
'att_dir' => $imagePath,
|
||
'satt_dir' => $imagePath,
|
||
'att_size' => $uploadValue['size'],
|
||
'att_type' => $uploadValue['mime'],
|
||
'image_type' => $uploadValue['image_type'],
|
||
'module_type' => 1,
|
||
'time' => time(),
|
||
'pid' => $pid
|
||
]);
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
}
|