Merge branch 'master' of https://gitee.com/v_cms/zyt
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
namespace app\adminapi\controller;
|
||||
|
||||
|
||||
use app\common\service\DirectUploadService;
|
||||
use app\common\service\UploadService;
|
||||
use Exception;
|
||||
use think\response\Json;
|
||||
@@ -77,4 +78,41 @@ class UploadController extends BaseAdminController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 浏览器直传 OSS - 签发 STS 临时凭证
|
||||
* @return Json
|
||||
*/
|
||||
public function ossCredentials()
|
||||
{
|
||||
$type = trim((string)$this->request->post('type', 'video'));
|
||||
try {
|
||||
$result = DirectUploadService::issueCredentials($type);
|
||||
return $this->success('ok', $result);
|
||||
} catch (Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 浏览器直传 OSS - 上传完成回执(写 file 表 + HEAD 校验)
|
||||
* @return Json
|
||||
*/
|
||||
public function ossConfirm()
|
||||
{
|
||||
try {
|
||||
$result = DirectUploadService::confirm([
|
||||
'type' => trim((string)$this->request->post('type', 'video')),
|
||||
'key' => trim((string)$this->request->post('key', '')),
|
||||
'name' => trim((string)$this->request->post('name', '')),
|
||||
'size' => (int)$this->request->post('size', 0),
|
||||
'content_type' => trim((string)$this->request->post('content_type', '')),
|
||||
'cid' => (int)$this->request->post('cid', 0),
|
||||
'admin_id' => $this->adminId,
|
||||
]);
|
||||
return $this->success('上传成功', $result);
|
||||
} catch (Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -501,6 +501,24 @@ class DiagnosisController extends BaseAdminController
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 手动上传视频前,先创建一条模拟通话记录
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function createManualCallRecord()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('trackingNotes');
|
||||
$result = DiagnosisLogic::createManualCallRecord([
|
||||
'diagnosis_id' => (int)$params['diagnosis_id'],
|
||||
'admin_id' => (int)$this->adminId,
|
||||
]);
|
||||
if (!$result) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('创建成功', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 医助旁观诊单当前视频通话(TRTC 进房参数,仅拉流)
|
||||
* @return \think\response\Json
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\enum\FileEnum;
|
||||
use app\common\model\file\File;
|
||||
use app\common\service\storage\engine\Qcloud as QcloudEngine;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* 浏览器直传 OSS 服务
|
||||
* - 目前仅支持腾讯云 COS(qcloud)
|
||||
* - 其他 driver 返回 fallback=true,由前端降级到老链路
|
||||
*
|
||||
* Class DirectUploadService
|
||||
* @package app\common\service
|
||||
*/
|
||||
class DirectUploadService
|
||||
{
|
||||
/** 视频允许的扩展名(沿用 config/project.file_video) */
|
||||
public const TYPE_VIDEO = 'video';
|
||||
|
||||
/** 默认凭证有效期 30 分钟 */
|
||||
public const DEFAULT_DURATION = 1800;
|
||||
|
||||
/** 允许扩展类型 → 大小上限(字节) */
|
||||
private const MAX_SIZE = [
|
||||
self::TYPE_VIDEO => 2 * 1024 * 1024 * 1024, // 2GB
|
||||
];
|
||||
|
||||
/**
|
||||
* @notes 签发临时凭证
|
||||
* @param string $type
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function issueCredentials(string $type): array
|
||||
{
|
||||
if (!isset(self::MAX_SIZE[$type])) {
|
||||
throw new Exception('不支持的上传类型: ' . $type);
|
||||
}
|
||||
|
||||
$default = ConfigService::get('storage', 'default', 'local');
|
||||
if ($default !== 'qcloud') {
|
||||
// 其他 driver 不支持直传,前端降级
|
||||
return ['provider' => $default, 'fallback' => true];
|
||||
}
|
||||
|
||||
$storageConfig = ConfigService::get('storage', 'qcloud');
|
||||
if (empty($storageConfig['bucket']) || empty($storageConfig['region'])
|
||||
|| empty($storageConfig['access_key']) || empty($storageConfig['secret_key'])) {
|
||||
throw new Exception('腾讯云 COS 配置不完整');
|
||||
}
|
||||
|
||||
$keyPrefix = self::buildKeyPrefix($type);
|
||||
$engine = new QcloudEngine($storageConfig);
|
||||
$sts = $engine->getStsCredentials($keyPrefix, self::MAX_SIZE[$type], self::DEFAULT_DURATION);
|
||||
|
||||
return [
|
||||
'provider' => 'qcloud',
|
||||
'fallback' => false,
|
||||
'bucket' => $sts['bucket'],
|
||||
'region' => $sts['region'],
|
||||
'host' => $sts['host'],
|
||||
'cdn_domain' => rtrim((string)($storageConfig['domain'] ?? ''), '/'),
|
||||
'key_prefix' => $keyPrefix,
|
||||
'max_size' => self::MAX_SIZE[$type],
|
||||
'duration' => self::DEFAULT_DURATION,
|
||||
'expired_time' => $sts['expiredTime'],
|
||||
'start_time' => $sts['startTime'],
|
||||
'credentials' => $sts['credentials'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 直传完成后的回执:HEAD 校验 + 写 file 表
|
||||
* @param array $params {key, type, name, size, content_type, cid, admin_id}
|
||||
* @return array {id, cid, type, name, uri, url}
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function confirm(array $params): array
|
||||
{
|
||||
$type = (string)($params['type'] ?? '');
|
||||
if (!isset(self::MAX_SIZE[$type])) {
|
||||
throw new Exception('不支持的上传类型');
|
||||
}
|
||||
|
||||
$default = ConfigService::get('storage', 'default', 'local');
|
||||
if ($default !== 'qcloud') {
|
||||
throw new Exception('当前存储驱动不支持直传回执');
|
||||
}
|
||||
|
||||
$key = ltrim((string)($params['key'] ?? ''), '/');
|
||||
$allowedPrefix = self::buildKeyPrefix($type);
|
||||
if ($key === '' || strpos($key, $allowedPrefix) !== 0) {
|
||||
throw new Exception('对象 Key 非法');
|
||||
}
|
||||
|
||||
$storageConfig = ConfigService::get('storage', 'qcloud');
|
||||
$engine = new QcloudEngine($storageConfig);
|
||||
$head = $engine->headObject($key);
|
||||
if ($head === false) {
|
||||
throw new Exception('对象未找到,请确认上传是否完成');
|
||||
}
|
||||
if ($head['size'] <= 0 || $head['size'] > self::MAX_SIZE[$type]) {
|
||||
throw new Exception('文件大小超出限制');
|
||||
}
|
||||
|
||||
$name = trim((string)($params['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = basename($key);
|
||||
}
|
||||
if (strlen($name) > 128) {
|
||||
$name = substr($name, 0, 123) . substr($name, -5);
|
||||
}
|
||||
|
||||
$file = File::create([
|
||||
'cid' => (int)($params['cid'] ?? 0),
|
||||
'type' => self::resolveFileType($type),
|
||||
'name' => $name,
|
||||
'uri' => $key,
|
||||
'source' => FileEnum::SOURCE_ADMIN,
|
||||
'source_id' => (int)($params['admin_id'] ?? 0),
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
$url = FileService::getFileUrl($key);
|
||||
return [
|
||||
'id' => $file['id'],
|
||||
'cid' => $file['cid'],
|
||||
'type' => $file['type'],
|
||||
'name' => $file['name'],
|
||||
'uri' => $url,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildKeyPrefix(string $type): string
|
||||
{
|
||||
return 'uploads/' . $type . '/' . date('Ymd') . '/';
|
||||
}
|
||||
|
||||
private static function resolveFileType(string $type): int
|
||||
{
|
||||
return match ($type) {
|
||||
self::TYPE_VIDEO => FileEnum::VIDEO_TYPE,
|
||||
default => FileEnum::FILE_TYPE,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace app\common\service\storage\engine;
|
||||
|
||||
use Exception;
|
||||
use Qcloud\Cos\Client;
|
||||
use QCloud\COSSTS\Sts;
|
||||
|
||||
/**
|
||||
* 腾讯云存储引擎 (COS)
|
||||
@@ -113,4 +114,125 @@ class Qcloud extends Server
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取 STS 临时凭证(用于浏览器直传)
|
||||
* @param string $keyPrefix 资源前缀,如 uploads/video/20260508/
|
||||
* @param int $maxSizeBytes 单文件大小上限(字节)
|
||||
* @param int $durationSeconds 凭证有效期(秒)
|
||||
* @return array {credentials, expiredTime, requestId}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getStsCredentials(string $keyPrefix, int $maxSizeBytes, int $durationSeconds = 1800): array
|
||||
{
|
||||
$bucket = $this->config['bucket'];
|
||||
// bucket 形如 likeadmin-1300000000,appId 即末段
|
||||
$appId = '';
|
||||
if (strpos($bucket, '-') !== false) {
|
||||
$parts = explode('-', $bucket);
|
||||
$appId = end($parts);
|
||||
}
|
||||
if (!$appId || !ctype_digit($appId)) {
|
||||
throw new Exception('腾讯云 COS bucket 名称不合法,无法解析 appId(期望形如 name-1300000000)');
|
||||
}
|
||||
|
||||
$shortBucket = substr($bucket, 0, strrpos($bucket, '-'));
|
||||
$region = $this->config['region'];
|
||||
$prefix = ltrim($keyPrefix, '/');
|
||||
if ($prefix === '' || substr($prefix, -1) !== '/') {
|
||||
$prefix = $prefix . '/';
|
||||
}
|
||||
|
||||
$duration = max(900, min($durationSeconds, 7200));
|
||||
|
||||
// 自行构造 policy:对象级写动作收紧 + bucket 级 ListMultipartUploads(cos-js-sdk-v5 续传探测必需)
|
||||
$objectArn = sprintf('qcs::cos:%s:uid/%s:%s/%s*', $region, $appId, $bucket, $prefix);
|
||||
$bucketArn = sprintf('qcs::cos:%s:uid/%s:%s/*', $region, $appId, $bucket);
|
||||
|
||||
$policy = [
|
||||
'version' => '2.0',
|
||||
'statement' => [
|
||||
[
|
||||
'effect' => 'allow',
|
||||
'action' => [
|
||||
'name/cos:PostObject',
|
||||
'name/cos:PutObject',
|
||||
'name/cos:InitiateMultipartUpload',
|
||||
'name/cos:UploadPart',
|
||||
'name/cos:CompleteMultipartUpload',
|
||||
'name/cos:AbortMultipartUpload',
|
||||
'name/cos:ListParts',
|
||||
],
|
||||
'resource' => [$objectArn],
|
||||
'condition' => [
|
||||
'numeric_less_than_equal' => [
|
||||
'cos:content-length' => $maxSizeBytes,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
// ListMultipartUploads 是 bucket 级操作,无法用对象级 ARN 命中
|
||||
'effect' => 'allow',
|
||||
'action' => ['name/cos:ListMultipartUploads'],
|
||||
'resource' => [$bucketArn],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$config = [
|
||||
'url' => 'https://sts.tencentcloudapi.com/',
|
||||
'domain' => 'sts.tencentcloudapi.com',
|
||||
'proxy' => '',
|
||||
'secretId' => $this->config['access_key'],
|
||||
'secretKey' => $this->config['secret_key'],
|
||||
'bucket' => $bucket,
|
||||
'region' => $region,
|
||||
'durationSeconds' => $duration,
|
||||
'policy' => $policy,
|
||||
];
|
||||
|
||||
$sts = new Sts();
|
||||
$result = $sts->getTempKeys($config);
|
||||
if (!is_array($result) || empty($result['credentials'])) {
|
||||
throw new Exception('获取 STS 临时凭证失败');
|
||||
}
|
||||
|
||||
return [
|
||||
'credentials' => [
|
||||
'tmpSecretId' => $result['credentials']['tmpSecretId'] ?? '',
|
||||
'tmpSecretKey' => $result['credentials']['tmpSecretKey'] ?? '',
|
||||
'sessionToken' => $result['credentials']['sessionToken'] ?? '',
|
||||
],
|
||||
'expiredTime' => (int)($result['expiredTime'] ?? (time() + $duration)),
|
||||
'startTime' => (int)($result['startTime'] ?? time()),
|
||||
'bucket' => $bucket,
|
||||
'shortBucket' => $shortBucket,
|
||||
'region' => $region,
|
||||
'appId' => $appId,
|
||||
'host' => sprintf('https://%s.cos.%s.myqcloud.com', $bucket, $region),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes HEAD 校验对象是否真实存在并返回 size / contentType
|
||||
* @param string $key
|
||||
* @return array|false
|
||||
*/
|
||||
public function headObject(string $key)
|
||||
{
|
||||
try {
|
||||
$result = $this->cosClient->headObject([
|
||||
'Bucket' => $this->config['bucket'],
|
||||
'Key' => $key,
|
||||
]);
|
||||
return [
|
||||
'size' => (int)($result['ContentLength'] ?? 0),
|
||||
'content_type' => (string)($result['ContentType'] ?? ''),
|
||||
'etag' => trim((string)($result['ETag'] ?? ''), '"'),
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
$this->error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user