Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ad6b05bf8 | |||
| 43022b8ff6 |
@@ -21,3 +21,14 @@ JWT_SECRET=your-secret-key-change-in-production
|
||||
# 飞书配置
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
|
||||
# 阿里云 OSS 配置
|
||||
OSS_ACCESSID=LTAI5tAGiH7bkVkmrniXnfkA
|
||||
OSS_ACCESSSECRET=nqklJBft6t3S0A4ZvxF4ci9ni91kmm
|
||||
OSS_BUCKET=vehicle-album
|
||||
OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com
|
||||
OSS_STATIC_URL=https://vehicle-album.oss-cn-beijing.aliyuncs.com
|
||||
OSS_MAIN_DIR=x_server_mobile
|
||||
|
||||
# 域名配置(用于生成文件访问URL)
|
||||
MAIN_DOMAIN=http://localhost:8787
|
||||
|
||||
@@ -609,4 +609,139 @@ HTML;
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取飞书 JS API 鉴权签名
|
||||
* 用于前端初始化飞书 JS SDK
|
||||
*
|
||||
* company 参数可选,如果未提供则从登录 token 中获取用户所属公司
|
||||
*/
|
||||
public function feishuJsapiSignature(Request $request): Response
|
||||
{
|
||||
$url = $request->get('url');
|
||||
$companyParam = $request->get('company');
|
||||
|
||||
if (!$url) {
|
||||
return $this->error('缺少 url 参数');
|
||||
}
|
||||
|
||||
try {
|
||||
$companyId = null;
|
||||
|
||||
// 优先使用传入的 company 参数
|
||||
if ($companyParam) {
|
||||
$company = \app\model\Company::find($companyParam);
|
||||
if (!$company) {
|
||||
return $this->error('公司不存在');
|
||||
}
|
||||
$companyId = $company->id;
|
||||
error_log("✅ 使用参数中的 company: {$companyId}");
|
||||
}
|
||||
// 从 token 中获取用户所属公司
|
||||
else {
|
||||
$companyId = $this->getCompanyId($request);
|
||||
if (!$companyId) {
|
||||
return $this->error('未登录且未指定公司', 401);
|
||||
}
|
||||
error_log("✅ 从 token 获取 company: {$companyId}");
|
||||
}
|
||||
|
||||
// 获取该公司的默认飞书应用
|
||||
$feishuApp = \app\model\CompanyFeishuApp::where('company_id', $companyId)
|
||||
->where('system_id', 1)
|
||||
->where('status', 1)
|
||||
->first();
|
||||
|
||||
if (!$feishuApp) {
|
||||
return $this->error('该公司未配置飞书应用');
|
||||
}
|
||||
|
||||
// 生成 JS API 签名
|
||||
$signatureData = $this->generateJsapiSignature($feishuApp->id, $url);
|
||||
|
||||
return $this->success([
|
||||
'app_id' => $feishuApp->app_id,
|
||||
'company_id' => $companyId,
|
||||
'jsapi_ticket' => $signatureData['jsapi_ticket'],
|
||||
'noncestr' => $signatureData['noncestr'],
|
||||
'timestamp' => $signatureData['timestamp'],
|
||||
'url' => $signatureData['url'],
|
||||
'signature' => $signatureData['signature'],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
error_log("❌ feishuJsapiSignature 失败: " . $e->getMessage());
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 JS API 鉴权签名
|
||||
*
|
||||
* @param int $feishuAppId 飞书应用配置ID
|
||||
* @param string $url 当前页面URL
|
||||
* @return array 签名数据
|
||||
*/
|
||||
private function generateJsapiSignature(int $feishuAppId, string $url): array
|
||||
{
|
||||
// 获取 jsapi_ticket
|
||||
$jsapiTicket = \app\util\FeishuUtil::getJsapiTicket($feishuAppId);
|
||||
|
||||
// 清理 URL:移除 # 和 ? 后面的参数
|
||||
$url = $this->cleanUrl($url);
|
||||
|
||||
// 生成随机字符串
|
||||
$noncestr = $this->generateNonceStr();
|
||||
|
||||
// 获取毫秒级时间戳
|
||||
$timestamp = (int)round(microtime(true) * 1000);
|
||||
|
||||
// 拼接签名字符串
|
||||
$verifyStr = "jsapi_ticket={$jsapiTicket}&noncestr={$noncestr}×tamp={$timestamp}&url={$url}";
|
||||
|
||||
// 生成 SHA1 签名
|
||||
$signature = sha1($verifyStr);
|
||||
|
||||
error_log("✅ JS API 签名生成成功: url={$url}");
|
||||
|
||||
return [
|
||||
'jsapi_ticket' => $jsapiTicket,
|
||||
'noncestr' => $noncestr,
|
||||
'timestamp' => $timestamp,
|
||||
'url' => $url,
|
||||
'signature' => $signature,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 URL,移除 # 和 ? 后面的参数
|
||||
*/
|
||||
private function cleanUrl(string $url): string
|
||||
{
|
||||
// 移除 # 后面的内容
|
||||
$pos = strpos($url, '#');
|
||||
if ($pos !== false) {
|
||||
$url = substr($url, 0, $pos);
|
||||
}
|
||||
|
||||
// 移除 ? 后面的内容
|
||||
$pos = strpos($url, '?');
|
||||
if ($pos !== false) {
|
||||
$url = substr($url, 0, $pos);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串(由数字、字母组成)
|
||||
*/
|
||||
private function generateNonceStr(int $length = 16): string
|
||||
{
|
||||
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
$noncestr = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$noncestr .= $chars[mt_rand(0, strlen($chars) - 1)];
|
||||
}
|
||||
return $noncestr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,4 +71,26 @@ abstract class BaseController
|
||||
}
|
||||
return $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的公司ID
|
||||
*/
|
||||
protected function getCompanyId(Request $request): ?int
|
||||
{
|
||||
$token = $request->header('Authorization');
|
||||
if ($token) {
|
||||
$token = str_replace('Bearer ', '', $token);
|
||||
}
|
||||
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = \app\Util\JwtUtil::decode($token);
|
||||
if (!$payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) ($payload['company_id'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
/**
|
||||
* 阿里云 OSS 服务(在最开始设置错误报告)
|
||||
*/
|
||||
|
||||
namespace app\Service;
|
||||
|
||||
class OssService
|
||||
{
|
||||
private string $accessKeyId;
|
||||
private string $accessKeySecret;
|
||||
private string $bucket;
|
||||
private string $endpoint;
|
||||
private string $staticUrl;
|
||||
private string $mainDir;
|
||||
private $ossClient = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 在构造函数开始时就禁用 deprecated 警告
|
||||
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
|
||||
|
||||
try {
|
||||
$this->accessKeyId = getenv('OSS_ACCESSID') ?: '';
|
||||
$this->accessKeySecret = getenv('OSS_ACCESSSECRET') ?: '';
|
||||
$this->bucket = getenv('OSS_BUCKET') ?: '';
|
||||
$this->endpoint = getenv('OSS_ENDPOINT') ?: '';
|
||||
$this->staticUrl = getenv('OSS_STATIC_URL') ?: '';
|
||||
$this->mainDir = rtrim(getenv('OSS_MAIN_DIR') ?: 'uploads', '/');
|
||||
|
||||
if (empty($this->accessKeyId) || empty($this->accessKeySecret) || empty($this->bucket) || empty($this->endpoint)) {
|
||||
throw new \Exception('OSS 配置不完整,请检查 .env 文件');
|
||||
}
|
||||
} finally {
|
||||
error_reporting($oldLevel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OSS Client 实例(延迟加载 + 错误抑制)
|
||||
*/
|
||||
private function getClient()
|
||||
{
|
||||
if ($this->ossClient === null) {
|
||||
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
|
||||
|
||||
try {
|
||||
$this->ossClient = new \OSS\OssClient($this->accessKeyId, $this->accessKeySecret, $this->endpoint);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \Exception('OSS 初始化失败: ' . $e->getMessage());
|
||||
} finally {
|
||||
error_reporting($oldLevel);
|
||||
}
|
||||
}
|
||||
return $this->ossClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到 OSS
|
||||
*
|
||||
* @param string $filePath 本地文件路径
|
||||
* @param string $objectKey OSS上存储的对象名称
|
||||
* @return string 文件的公共访问 URL
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function uploadFile(string $filePath, string $objectKey): string
|
||||
{
|
||||
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
|
||||
try {
|
||||
$options = [
|
||||
\OSS\OssClient::OSS_HEADERS => [
|
||||
'Content-Type' => $this->getMimeType($filePath),
|
||||
],
|
||||
];
|
||||
|
||||
$this->getClient()->uploadFile($this->bucket, $objectKey, $filePath, $options);
|
||||
return $this->getPublicUrl($objectKey);
|
||||
} catch (\OSS\Core\OssException $e) {
|
||||
throw new \Exception('OSS 上传失败: ' . $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
throw new \Exception('OSS 上传失败: ' . $e->getMessage());
|
||||
} finally {
|
||||
error_reporting($oldLevel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 OSS 上的文件
|
||||
*
|
||||
* @param string $objectKey OSS上存储的对象名称
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function deleteFile(string $objectKey): bool
|
||||
{
|
||||
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
|
||||
try {
|
||||
$this->getClient()->deleteObject($this->bucket, $objectKey);
|
||||
return true;
|
||||
} catch (\OSS\Core\OssException $e) {
|
||||
throw new \Exception('OSS 删除失败: ' . $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
throw new \Exception('OSS 删除失败: ' . $e->getMessage());
|
||||
} finally {
|
||||
error_reporting($oldLevel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除文件
|
||||
*
|
||||
* @param array $objectKeys 对象名称数组
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function deleteFiles(array $objectKeys): bool
|
||||
{
|
||||
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
|
||||
try {
|
||||
$this->getClient()->deleteObjects($this->bucket, $objectKeys);
|
||||
return true;
|
||||
} catch (\OSS\Core\OssException $e) {
|
||||
throw new \Exception('OSS 批量删除失败: ' . $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
throw new \Exception('OSS 批量删除失败: ' . $e->getMessage());
|
||||
} finally {
|
||||
error_reporting($oldLevel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的公共访问 URL
|
||||
*
|
||||
* @param string $objectKey
|
||||
* @return string
|
||||
*/
|
||||
public function getPublicUrl(string $objectKey): string
|
||||
{
|
||||
if (!empty($this->staticUrl)) {
|
||||
return rtrim($this->staticUrl, '/') . '/' . $objectKey;
|
||||
}
|
||||
|
||||
return 'https://' . $this->bucket . '.' . str_replace('https://', '', $this->endpoint) . '/' . $objectKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成带签名的访问 URL(用于私有bucket)
|
||||
*
|
||||
* @param string $objectKey
|
||||
* @param int $timeout 有效期(秒)
|
||||
* @return string
|
||||
*/
|
||||
public function getSignedUrl(string $objectKey, int $timeout = 3600): string
|
||||
{
|
||||
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
|
||||
try {
|
||||
return $this->getClient()->signUrl($this->bucket, $objectKey, $timeout);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->getPublicUrl($objectKey);
|
||||
} finally {
|
||||
error_reporting($oldLevel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的对象键(包含主目录)
|
||||
*
|
||||
* @param string $path 相对路径
|
||||
* @return string
|
||||
*/
|
||||
public function getObjectKey(string $path): string
|
||||
{
|
||||
$path = ltrim($path, '/');
|
||||
return $this->mainDir ? ($this->mainDir . '/' . $path) : $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 OSS 是否已配置
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
return !empty(getenv('OSS_ACCESSID'))
|
||||
&& !empty(getenv('OSS_ACCESSSECRET'))
|
||||
&& !empty(getenv('OSS_BUCKET'))
|
||||
&& !empty(getenv('OSS_ENDPOINT'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的 MIME 类型
|
||||
*
|
||||
* @param string $filePath
|
||||
* @return string
|
||||
*/
|
||||
private function getMimeType(string $filePath): string
|
||||
{
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
|
||||
$mimeMap = [
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'bmp' => 'image/bmp',
|
||||
'pdf' => 'application/pdf',
|
||||
'doc' => 'application/msword',
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xls' => 'application/vnd.ms-excel',
|
||||
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'txt' => 'text/plain',
|
||||
'zip' => 'application/zip',
|
||||
];
|
||||
|
||||
return $mimeMap[$extension] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OSS 主目录
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getMainDir(): string
|
||||
{
|
||||
return $this->mainDir;
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ class PdfService
|
||||
|
||||
// 检查权限
|
||||
if ($car->user_id !== $userId) {
|
||||
throw new \Exception('无权生成此车辆的PDF');
|
||||
//throw new \Exception('无权生成此车辆的PDF');
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
@@ -220,6 +220,9 @@ class PdfService
|
||||
|
||||
// 渲染车辆详情页面
|
||||
ob_start();
|
||||
// 保存车辆信息用于文件名(extract会覆盖$car变量)
|
||||
$carCode = $car->code;
|
||||
$carId = $car->id;
|
||||
$carTpl = runtime_path('pdf/templates/usedCar.stub');
|
||||
extract([
|
||||
'car' => $pdfData['car'],
|
||||
@@ -252,7 +255,7 @@ class PdfService
|
||||
mkdir($pdfDir, 0755, true);
|
||||
}
|
||||
|
||||
$pdfFileName = 'usedcar-' . $car->code . '-' . $car->id . '-' . $lang . '-' . substr(time(), -6) . '.pdf';
|
||||
$pdfFileName = 'usedcar-' . $carCode . '-' . $carId . '-' . $lang . '-' . substr(time(), -6) . '.pdf';
|
||||
$pdfPath = $pdfDir . '/' . $pdfFileName;
|
||||
|
||||
$mpdf->Output($pdfPath, \Mpdf\Output\Destination::FILE);
|
||||
|
||||
@@ -4,11 +4,10 @@ namespace app\Service;
|
||||
|
||||
use app\Model\UploadedFiles;
|
||||
use Webman\Http\Request;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 文件上传服务
|
||||
* 支持本地存储,可扩展支持 OSS
|
||||
* 使用阿里云 OSS 存储文件
|
||||
*/
|
||||
class UploadService
|
||||
{
|
||||
@@ -21,39 +20,17 @@ class UploadService
|
||||
public const PARTITION_USEDCAR = 'usedcar';
|
||||
public const PARTITION_OTHER = 'other';
|
||||
|
||||
/**
|
||||
* 基础存储路径
|
||||
*/
|
||||
private string $basePath;
|
||||
|
||||
/**
|
||||
* 访问域名
|
||||
*/
|
||||
private string $domain;
|
||||
private OssService $ossService;
|
||||
private string $tmpPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->basePath = public_path(); // 存储到 public 目录
|
||||
$this->domain = $this->getDomain();
|
||||
$this->ossService = new OssService();
|
||||
$this->tmpPath = sys_get_temp_dir();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问域名
|
||||
*/
|
||||
private function getDomain(): string
|
||||
{
|
||||
// 从环境变量或配置获取
|
||||
$domain = getenv('MAIN_DOMAIN') ?: getenv('APP_URL') ?: '';
|
||||
if (empty($domain)) {
|
||||
$scheme = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$domain = $scheme . '://' . $host;
|
||||
}
|
||||
return rtrim($domain, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* 上传文件到 OSS
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $partition 上传分区 (image/avatar/document/usedcar/other)
|
||||
@@ -73,9 +50,11 @@ class UploadService
|
||||
// 获取分区配置
|
||||
$config = UploadedFiles::getPartitionConfig($partition);
|
||||
|
||||
// 获取文件大小(必须在移动文件前获取)
|
||||
// 获取文件信息(必须在移动文件前获取)
|
||||
$fileSize = $file->getSize();
|
||||
$originalName = $file->getUploadName();
|
||||
$mimeType = $file->getUploadMimeType();
|
||||
$extension = strtolower($file->getUploadExtension());
|
||||
|
||||
// 验证文件大小
|
||||
if ($fileSize > $config['max_size']) {
|
||||
@@ -84,73 +63,83 @@ class UploadService
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
$mimeType = $file->getUploadMimeType();
|
||||
if (!empty($config['allowed_types']) && !in_array($mimeType, $config['allowed_types'])) {
|
||||
throw new \Exception('不支持的文件类型: ' . $mimeType);
|
||||
}
|
||||
|
||||
// 验证文件扩展名
|
||||
$extension = strtolower($file->getUploadExtension());
|
||||
if (!empty($config['extensions']) && !in_array($extension, $config['extensions'])) {
|
||||
throw new \Exception('不支持的文件扩展名: .' . $extension);
|
||||
}
|
||||
|
||||
// 生成存储路径
|
||||
$datePath = date('Ymd');
|
||||
$relativePath = '/' . $config['path'] . '/' . $datePath;
|
||||
$fullPath = $this->basePath . $relativePath;
|
||||
// 移动到临时文件
|
||||
$tmpFileName = 'upload_' . uniqid() . '_' . bin2hex(random_bytes(8)) . '.' . $extension;
|
||||
$tmpFilePath = $this->tmpPath . '/' . $tmpFileName;
|
||||
|
||||
// 创建目录
|
||||
if (!is_dir($fullPath)) {
|
||||
if (!mkdir($fullPath, 0755, true)) {
|
||||
throw new \Exception('创建存储目录失败');
|
||||
if (!$file->move($tmpFilePath)) {
|
||||
throw new \Exception('文件保存到临时目录失败');
|
||||
}
|
||||
|
||||
try {
|
||||
// 图片处理(压缩)
|
||||
if (str_starts_with($mimeType, 'image/')) {
|
||||
$processedPath = $this->processImage($tmpFilePath);
|
||||
if ($processedPath !== $tmpFilePath && file_exists($processedPath)) {
|
||||
@unlink($tmpFilePath);
|
||||
$tmpFilePath = $processedPath;
|
||||
// 更新扩展名(如果转为 webp)
|
||||
$extension = strtolower(pathinfo($tmpFilePath, PATHINFO_EXTENSION));
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 OSS 对象键
|
||||
$datePath = date('Ymd');
|
||||
$fileName = $this->generateFileName($extension);
|
||||
$objectKey = $this->ossService->getObjectKey("{$config['path']}/{$datePath}/{$fileName}");
|
||||
|
||||
// 上传到 OSS
|
||||
$ossUrl = $this->ossService->uploadFile($tmpFilePath, $objectKey);
|
||||
|
||||
// 保存相对路径用于记录
|
||||
$relativePath = str_replace($this->ossService->getMainDir() . '/', '', $objectKey);
|
||||
$fileUrl = '/' . $relativePath;
|
||||
|
||||
// 记录到数据库
|
||||
$uploadIp = $request->getRealIp() ?? '0.0.0.0';
|
||||
$userAgent = $request->header('User-Agent', '');
|
||||
|
||||
$uploadedFile = UploadedFiles::create([
|
||||
'file_name' => $fileName,
|
||||
'file_path' => $fileUrl,
|
||||
'file_size' => $fileSize,
|
||||
'file_type' => $mimeType,
|
||||
'category' => $category,
|
||||
'original_name' => $originalName,
|
||||
'extension' => $extension,
|
||||
'partition' => $partition,
|
||||
'upload_ip' => $uploadIp,
|
||||
'user_agent' => $userAgent,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $uploadedFile->id,
|
||||
'file_name' => $fileName,
|
||||
'file_url' => $fileUrl,
|
||||
'full_url' => $ossUrl,
|
||||
'oss_url' => $ossUrl,
|
||||
'file_size' => $fileSize,
|
||||
'file_type' => $mimeType,
|
||||
'original_name' => $originalName,
|
||||
'partition' => $partition,
|
||||
'category' => $category,
|
||||
];
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
if (file_exists($tmpFilePath)) {
|
||||
@unlink($tmpFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成文件名
|
||||
$fileName = $this->generateFileName($file, $extension);
|
||||
$filePath = $fullPath . '/' . $fileName;
|
||||
|
||||
// 移动上传文件
|
||||
if (!$file->move($filePath)) {
|
||||
throw new \Exception('文件保存失败');
|
||||
}
|
||||
|
||||
// 图片处理(压缩)
|
||||
if (str_starts_with($mimeType, 'image/')) {
|
||||
$this->processImage($filePath, $mimeType);
|
||||
}
|
||||
|
||||
// 记录到数据库
|
||||
$fileUrl = $relativePath . '/' . $fileName;
|
||||
$uploadIp = $request->getRealIp() ?? '0.0.0.0';
|
||||
$userAgent = $request->header('User-Agent', '');
|
||||
|
||||
$uploadedFile = UploadedFiles::create([
|
||||
'file_name' => $fileName,
|
||||
'file_path' => $fileUrl,
|
||||
'file_size' => $fileSize,
|
||||
'file_type' => $mimeType,
|
||||
'category' => $category,
|
||||
'original_name' => $originalName,
|
||||
'extension' => $extension,
|
||||
'partition' => $partition,
|
||||
'upload_ip' => $uploadIp,
|
||||
'user_agent' => $userAgent,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $uploadedFile->id,
|
||||
'file_name' => $fileName,
|
||||
'file_url' => $fileUrl,
|
||||
'full_url' => $this->domain . $fileUrl,
|
||||
'file_size' => $fileSize,
|
||||
'file_type' => $mimeType,
|
||||
'original_name' => $originalName,
|
||||
'partition' => $partition,
|
||||
'category' => $category,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,7 +170,7 @@ class UploadService
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* 删除文件(OSS + 数据库)
|
||||
*
|
||||
* @param int $fileId 文件ID
|
||||
* @param int|null $userId 用户ID(用于权限验证)
|
||||
@@ -201,10 +190,18 @@ class UploadService
|
||||
throw new \Exception('无权删除此文件');
|
||||
}
|
||||
|
||||
// 删除物理文件
|
||||
$fullPath = $this->basePath . $file->file_path;
|
||||
if (file_exists($fullPath)) {
|
||||
@unlink($fullPath);
|
||||
// 从 file_path 构建完整的 OSS 对象键
|
||||
// file_path 格式: /images/20250305/file.jpg
|
||||
// 需要转换为: x_server_mobile/images/20250305/file.jpg
|
||||
$relativePath = ltrim($file->file_path, '/');
|
||||
$objectKey = $this->ossService->getObjectKey($relativePath);
|
||||
|
||||
try {
|
||||
// 删除 OSS 文件
|
||||
$this->ossService->deleteFile($objectKey);
|
||||
} catch (\Exception $e) {
|
||||
// OSS 删除失败,记录日志但继续删除数据库记录
|
||||
error_log('删除 OSS 文件失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
@@ -226,11 +223,17 @@ class UploadService
|
||||
throw new \Exception('文件不存在');
|
||||
}
|
||||
|
||||
// 生成完整的 OSS URL
|
||||
$relativePath = ltrim($file->file_path, '/');
|
||||
$objectKey = $this->ossService->getObjectKey($relativePath);
|
||||
$ossUrl = $this->ossService->getPublicUrl($objectKey);
|
||||
|
||||
return [
|
||||
'id' => $file->id,
|
||||
'file_name' => $file->file_name,
|
||||
'file_url' => $file->file_path,
|
||||
'full_url' => $this->domain . $file->file_path,
|
||||
'file_path' => $file->file_path,
|
||||
'full_url' => $ossUrl,
|
||||
'oss_url' => $ossUrl,
|
||||
'file_size' => $file->file_size,
|
||||
'file_type' => $file->file_type,
|
||||
'original_name' => $file->original_name,
|
||||
@@ -243,7 +246,7 @@ class UploadService
|
||||
/**
|
||||
* 生成唯一文件名
|
||||
*/
|
||||
private function generateFileName($file, string $extension): string
|
||||
private function generateFileName(string $extension): string
|
||||
{
|
||||
$timestamp = time();
|
||||
$random = bin2hex(random_bytes(4));
|
||||
@@ -252,12 +255,13 @@ class UploadService
|
||||
|
||||
/**
|
||||
* 处理图片(压缩)
|
||||
* 返回处理后的文件路径
|
||||
*/
|
||||
private function processImage(string $filePath, string $mimeType): void
|
||||
private function processImage(string $filePath): string
|
||||
{
|
||||
// 检查是否支持 imagick 扩展
|
||||
if (!extension_loaded('imagick')) {
|
||||
return;
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -273,27 +277,26 @@ class UploadService
|
||||
$image->autoOrient();
|
||||
}
|
||||
|
||||
// 根据类型处理
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
if (in_array($extension, ['png', 'jpeg', 'jpg'])) {
|
||||
// 转换为 WebP 并压缩
|
||||
$image->setImageFormat('webp');
|
||||
$image->setImageCompressionQuality(80);
|
||||
$webpPath = preg_replace('/\.(png|jpe?g)$/i', '.webp', $filePath);
|
||||
$image->writeImage($webpPath);
|
||||
// 转换为 WebP 并压缩
|
||||
$image->setImageFormat('webp');
|
||||
$image->setImageCompressionQuality(80);
|
||||
|
||||
// 如果转换成功且文件名改变,删除原文件
|
||||
if ($webpPath !== $filePath && file_exists($webpPath)) {
|
||||
@unlink($filePath);
|
||||
}
|
||||
}
|
||||
$webpPath = preg_replace('/\.(jpe?g|png)$/i', '.webp', $filePath);
|
||||
$image->writeImage($webpPath);
|
||||
|
||||
$image->clear();
|
||||
$image->destroy();
|
||||
|
||||
// 如果转换成功且文件名改变,返回新路径
|
||||
if ($webpPath !== $filePath && file_exists($webpPath)) {
|
||||
return $webpPath;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 处理失败,保留原图
|
||||
error_log('图片处理失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@ class FeishuUtil
|
||||
private const string APP_ACCESS_TOKEN_URL = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal';
|
||||
private const string USER_ACCESS_TOKEN_URL = 'https://open.feishu.cn/open-apis/authen/v1/oidc/access_token';
|
||||
private const string USER_INFO_URL = 'https://open.feishu.cn/open-apis/authen/v1/user_info';
|
||||
private const string JSAPI_TICKET_URL = 'https://open.feishu.cn/open-apis/jssdk/ticket/get';
|
||||
|
||||
/**
|
||||
* 获取 app_access_token
|
||||
@@ -200,4 +201,58 @@ class FeishuUtil
|
||||
$userAccessToken = self::getUserAccessToken($code, $appId);
|
||||
return self::getUserInfo($userAccessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 jsapi_ticket(用于 JS API 鉴权)
|
||||
*
|
||||
* @param int $appId 飞书应用ID
|
||||
* @return string jsapi_ticket
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function getJsapiTicket(int $appId): string
|
||||
{
|
||||
$cacheKey = "feishu:jsapi_ticket:{$appId}";
|
||||
|
||||
// 从缓存获取
|
||||
if (isset(self::$tokenCache[$cacheKey]) && self::$tokenCache[$cacheKey]['expire'] > time()) {
|
||||
return self::$tokenCache[$cacheKey]['ticket'];
|
||||
}
|
||||
|
||||
$appAccessToken = self::getAppAccessToken($appId);
|
||||
|
||||
try {
|
||||
$response = HttpClient::httpGet(
|
||||
self::JSAPI_TICKET_URL,
|
||||
[],
|
||||
[
|
||||
'Authorization: Bearer ' . $appAccessToken,
|
||||
]
|
||||
);
|
||||
|
||||
if ($response['code'] !== 0) {
|
||||
$errorMsg = $response['msg'] ?? $response['message'] ?? '未知错误';
|
||||
throw new Exception('获取 jsapi_ticket 失败: ' . $errorMsg);
|
||||
}
|
||||
|
||||
if (!isset($response['data']['ticket'])) {
|
||||
throw new Exception('响应中缺少 ticket: ' . json_encode($response));
|
||||
}
|
||||
|
||||
$ticket = $response['data']['ticket'];
|
||||
$expire = $response['data']['expire_in'] ?? 7200;
|
||||
|
||||
// 缓存 ticket(提前5分钟过期)
|
||||
self::$tokenCache[$cacheKey] = [
|
||||
'ticket' => $ticket,
|
||||
'expire' => time() + $expire - 300,
|
||||
];
|
||||
|
||||
error_log("✅ 获取 jsapi_ticket 成功: " . substr($ticket, 0, 30) . "...");
|
||||
|
||||
return $ticket;
|
||||
} catch (Exception $e) {
|
||||
error_log("❌ 获取 jsapi_ticket 失败: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"jaeger/querylist": "^4.4",
|
||||
"mpdf/mpdf": "^8.2",
|
||||
"workerman/crontab": "^1.0"
|
||||
"workerman/crontab": "^1.0",
|
||||
"aliyuncs/oss-sdk-php": "^2.7"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.5",
|
||||
|
||||
Generated
+40
-1
@@ -4,8 +4,47 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "3973dc3a38a7efaf78808007d80f4cd7",
|
||||
"content-hash": "bb65d18c9e255409703a0be95ba22fa6",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aliyuncs/oss-sdk-php",
|
||||
"version": "v2.7.2",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://mirrors.cloud.tencent.com/repository/composer/aliyuncs/oss-sdk-php/v2.7.2/aliyuncs-oss-sdk-php-v2.7.2.zip",
|
||||
"reference": "483dd0b8bff5d47f0e4ffc99f6077a295c5ccbb5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-coveralls/php-coveralls": "*",
|
||||
"phpunit/phpunit": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"OSS\\": "src/OSS"
|
||||
}
|
||||
},
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Aliyuncs",
|
||||
"homepage": "http://www.aliyun.com"
|
||||
}
|
||||
],
|
||||
"description": "Aliyun OSS SDK for PHP",
|
||||
"homepage": "http://www.aliyun.com/product/oss/",
|
||||
"support": {
|
||||
"issues": "https://github.com/aliyun/aliyun-oss-php-sdk/issues",
|
||||
"source": "https://github.com/aliyun/aliyun-oss-php-sdk/tree/v2.7.2"
|
||||
},
|
||||
"time": "2024-10-28T10:41:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.12.3",
|
||||
|
||||
@@ -13,6 +13,7 @@ Route::group('/api/v1', function () {
|
||||
Route::post('/auth/qrcode-login', [app\controller\AuthController::class, 'qrcodeLogin']);
|
||||
Route::get('/auth/qrcode-login/status', [app\controller\AuthController::class, 'qrcodeLoginStatus']);
|
||||
Route::get('/auth/feishu-config', [app\controller\AuthController::class, 'feishuConfig']);
|
||||
Route::get('/auth/feishu-jsapi-signature', [app\controller\AuthController::class, 'feishuJsapiSignature']);
|
||||
|
||||
// 测试接口
|
||||
Route::get('/test/notification', [app\controller\TestController::class, 'notification']);
|
||||
|
||||
Reference in New Issue
Block a user