mirror of
https://github.com/kuaifan/dootask.git
synced 2026-08-10 23:08:34 +00:00
feat(file): switch collaboration files to attachment index
This commit is contained in:
parent
380bfaaea6
commit
fd37fcf6f6
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogMsgAttachment;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\AbstractModel;
|
||||
@ -36,7 +37,7 @@ class FileController extends AbstractController
|
||||
/**
|
||||
* @api {get} api/file/collaboration/lists 获取协作文件列表
|
||||
*
|
||||
* @apiDescription 汇总用户有权访问的会话、项目群聊和任务中的文件消息
|
||||
* @apiDescription 汇总用户有权访问的会话、项目群聊和任务中的文件消息及聊天正文图片
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup file
|
||||
* @apiName collaboration__lists
|
||||
@ -48,12 +49,14 @@ class FileController extends AbstractController
|
||||
* @apiParam {String} [file_type] 文件类型
|
||||
* @apiParam {Number} [sender_id] 发送人ID
|
||||
* @apiParam {String} [key] 搜索关键词
|
||||
* @apiParam {Number} [cursor] 上一页最后一条消息ID
|
||||
* @apiParam {String} [cursor] 上一页返回的不透明游标
|
||||
* @apiParam {Number} [take] 获取条数,默认50,最大100
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {Number} data.list[].attachment_id 附件索引ID
|
||||
* @apiSuccess {String} data.list[].attachment_source 附件来源:file_message、inline_image
|
||||
* @apiSuccess {String} data.list[].image_url 图片缩略图地址,非图片时为空
|
||||
*/
|
||||
public function collaboration__lists()
|
||||
@ -74,6 +77,39 @@ class FileController extends AbstractController
|
||||
return Base::retSuccess('success', CollaborationFileService::lists($user, $params));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/collaboration/download 下载协作文件附件
|
||||
*
|
||||
* @apiDescription 下载文件消息附件或聊天正文图片
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup file
|
||||
* @apiName collaboration__download
|
||||
*
|
||||
* @apiParam {Number} attachment_id 附件索引ID
|
||||
*/
|
||||
public function collaboration__download()
|
||||
{
|
||||
$attachment = WebSocketDialogMsgAttachment::whereId(intval(Request::input('attachment_id')))->first();
|
||||
abort_if(empty($attachment), 404, 'This file not exist.');
|
||||
|
||||
try {
|
||||
CollaborationFileService::authorizeAttachment($attachment, User::auth());
|
||||
} catch (\Throwable $e) {
|
||||
abort(403, $e->getMessage() ?: 'This file not support download.');
|
||||
}
|
||||
|
||||
$path = (string)$attachment->path;
|
||||
abort_if($path === '', 404, 'This file not exist.');
|
||||
if (preg_match('/^https?:\/\//i', $path)) {
|
||||
return Redirect::away($path);
|
||||
}
|
||||
|
||||
$filePath = CollaborationFileService::resolveLocalAttachmentPath($path);
|
||||
abort_if($filePath === null, 404, 'This file not exist.');
|
||||
$name = trim((string)$attachment->name) ?: basename($filePath);
|
||||
return Base::DownloadFileResponse($filePath, $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/lists 获取文件列表
|
||||
*
|
||||
|
||||
@ -9,6 +9,8 @@ use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogMsgAttachment;
|
||||
use App\Models\WebSocketDialogMsgAttachmentBackfill;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
use App\Module\Base;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -17,6 +19,9 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CollaborationFileService
|
||||
{
|
||||
private const CURSOR_SOURCE_MESSAGES = 'messages';
|
||||
private const CURSOR_SOURCE_ATTACHMENTS = 'attachments';
|
||||
|
||||
private const SCOPES = ['all', 'conversation', 'project'];
|
||||
private const CONVERSATION_TYPES = ['all', 'private', 'group'];
|
||||
private const PROJECT_SOURCES = ['all', 'project_chat', 'task'];
|
||||
@ -55,7 +60,7 @@ class CollaborationFileService
|
||||
'file_type' => $fileType,
|
||||
'sender_id' => max(0, intval($params['sender_id'] ?? 0)),
|
||||
'key' => mb_substr(trim((string)($params['key'] ?? '')), 0, 100),
|
||||
'cursor' => max(0, intval($params['cursor'] ?? 0)),
|
||||
'cursor' => mb_substr(trim((string)($params['cursor'] ?? '')), 0, 100),
|
||||
'take' => min(100, max(1, intval($params['take'] ?? 50))),
|
||||
];
|
||||
|
||||
@ -76,6 +81,19 @@ class CollaborationFileService
|
||||
}
|
||||
}
|
||||
|
||||
$cursorSource = self::cursorSource($params['cursor']);
|
||||
if ($cursorSource === self::CURSOR_SOURCE_MESSAGES) {
|
||||
return self::listsFromMessages($user, $params, $departmentView);
|
||||
}
|
||||
if ($cursorSource === self::CURSOR_SOURCE_ATTACHMENTS || self::attachmentIndexReady()) {
|
||||
return self::listsFromAttachments($user, $params, $departmentView);
|
||||
}
|
||||
|
||||
return self::listsFromMessages($user, $params, $departmentView);
|
||||
}
|
||||
|
||||
private static function listsFromMessages(User $user, array $params, array $departmentView): array
|
||||
{
|
||||
$query = WebSocketDialogMsg::query()
|
||||
->select([
|
||||
'web_socket_dialog_msgs.*',
|
||||
@ -109,7 +127,12 @@ class CollaborationFileService
|
||||
|
||||
self::applyAccess($query, $user, $departmentView);
|
||||
self::applyScope($query, $params);
|
||||
self::applyFilters($query, $params);
|
||||
self::applyFilters($query, $params, false);
|
||||
|
||||
$cursor = self::messageCursor($params['cursor']);
|
||||
if ($cursor > 0) {
|
||||
$query->where('web_socket_dialog_msgs.id', '<', $cursor);
|
||||
}
|
||||
|
||||
$rows = $query
|
||||
->orderByDesc('web_socket_dialog_msgs.id')
|
||||
@ -131,7 +154,76 @@ class CollaborationFileService
|
||||
|
||||
return [
|
||||
'list' => $list,
|
||||
'next_cursor' => $hasMore && $rows->isNotEmpty() ? intval($rows->last()->id) : 0,
|
||||
'next_cursor' => $hasMore && $rows->isNotEmpty() ? 'm:' . intval($rows->last()->id) : 0,
|
||||
'has_more' => $hasMore,
|
||||
];
|
||||
}
|
||||
|
||||
private static function listsFromAttachments(User $user, array $params, array $departmentView): array
|
||||
{
|
||||
$query = WebSocketDialogMsgAttachment::query()
|
||||
->select([
|
||||
'web_socket_dialog_msg_attachments.*',
|
||||
'messages.userid as sender_id',
|
||||
'messages.key as message_key',
|
||||
'messages.created_at as message_created_at',
|
||||
'dialogs.type as source_dialog_type',
|
||||
'dialogs.group_type as source_group_type',
|
||||
'dialogs.name as source_dialog_name',
|
||||
'project_chat.id as project_chat_id',
|
||||
'project_chat.name as project_chat_name',
|
||||
'project_task.id as source_task_id',
|
||||
'project_task.name as source_task_name',
|
||||
'project_task.complete_at as source_task_complete_at',
|
||||
'project_task.archived_at as source_task_archived_at',
|
||||
'task_project.id as task_project_id',
|
||||
'task_project.name as task_project_name',
|
||||
])
|
||||
->join('web_socket_dialog_msgs as messages', 'messages.id', '=', 'web_socket_dialog_msg_attachments.msg_id')
|
||||
->join('web_socket_dialogs as dialogs', 'dialogs.id', '=', 'messages.dialog_id')
|
||||
->leftJoin('projects as project_chat', function ($join) {
|
||||
$join->on('project_chat.dialog_id', '=', 'dialogs.id')
|
||||
->whereNull('project_chat.deleted_at');
|
||||
})
|
||||
->leftJoin('project_tasks as project_task', function ($join) {
|
||||
$join->on('project_task.dialog_id', '=', 'dialogs.id')
|
||||
->whereNull('project_task.deleted_at');
|
||||
})
|
||||
->leftJoin('projects as task_project', function ($join) {
|
||||
$join->on('task_project.id', '=', 'project_task.project_id')
|
||||
->whereNull('task_project.deleted_at');
|
||||
})
|
||||
->whereNull('messages.deleted_at')
|
||||
->whereNull('dialogs.deleted_at');
|
||||
|
||||
self::applyAccess($query, $user, $departmentView);
|
||||
self::applyScope($query, $params);
|
||||
self::applyFilters($query, $params, true);
|
||||
self::applyAttachmentCursor($query, $params['cursor']);
|
||||
|
||||
$rows = $query
|
||||
->orderBy('web_socket_dialog_msg_attachments.cursor_msg_id')
|
||||
->orderBy('web_socket_dialog_msg_attachments.position')
|
||||
->orderBy('web_socket_dialog_msg_attachments.id')
|
||||
->take($params['take'] + 1)
|
||||
->get();
|
||||
|
||||
$hasMore = $rows->count() > $params['take'];
|
||||
if ($hasMore) {
|
||||
$rows->pop();
|
||||
}
|
||||
|
||||
$userIds = $rows->pluck('sender_id')->map(fn($id) => intval($id))->filter()->unique()->values();
|
||||
$users = User::select(User::$basicField)->whereIn('userid', $userIds)->get()->keyBy('userid');
|
||||
$privateNames = self::privateDialogNames($rows, intval($user->userid));
|
||||
|
||||
$list = $rows->map(function (WebSocketDialogMsgAttachment $row) use ($users, $privateNames, $user) {
|
||||
return self::formatAttachmentRow($row, $users->get($row->sender_id), $privateNames, $user);
|
||||
})->values()->toArray();
|
||||
|
||||
return [
|
||||
'list' => $list,
|
||||
'next_cursor' => $hasMore && $rows->isNotEmpty() ? self::attachmentCursor($rows->last()) : 0,
|
||||
'has_more' => $hasMore,
|
||||
];
|
||||
}
|
||||
@ -168,6 +260,42 @@ class CollaborationFileService
|
||||
}
|
||||
}
|
||||
|
||||
public static function authorizeAttachment(WebSocketDialogMsgAttachment $attachment, User $user): WebSocketDialogMsg
|
||||
{
|
||||
$message = WebSocketDialogMsg::whereId($attachment->msg_id)->first();
|
||||
if (!$message || intval($message->dialog_id) !== intval($attachment->dialog_id)) {
|
||||
throw new ApiException('文件不存在或已被删除');
|
||||
}
|
||||
self::authorizeMessage($message, $user);
|
||||
return $message;
|
||||
}
|
||||
|
||||
public static function resolveLocalAttachmentPath(string $path): ?string
|
||||
{
|
||||
$urlPath = parse_url(trim($path), PHP_URL_PATH);
|
||||
if (!is_string($urlPath) || $urlPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$relativePath = rawurldecode($urlPath);
|
||||
if (str_contains($relativePath, "\0")) {
|
||||
return null;
|
||||
}
|
||||
$relativePath = ltrim(str_replace('\\', '/', $relativePath), '/');
|
||||
if (!str_starts_with($relativePath, 'uploads/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$uploadsRoot = realpath(public_path('uploads'));
|
||||
$filePath = realpath(public_path($relativePath));
|
||||
if ($uploadsRoot === false || $filePath === false || !is_file($filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$uploadsPrefix = rtrim($uploadsRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
return str_starts_with($filePath, $uploadsPrefix) ? $filePath : null;
|
||||
}
|
||||
|
||||
private static function applyAccess(Builder $query, User $user, array $departmentView): void
|
||||
{
|
||||
$userid = intval($user->userid);
|
||||
@ -233,38 +361,41 @@ class CollaborationFileService
|
||||
}
|
||||
}
|
||||
|
||||
private static function applyFilters(Builder $query, array $params): void
|
||||
private static function applyFilters(Builder $query, array $params, bool $indexed): void
|
||||
{
|
||||
$messageTable = $indexed ? 'messages' : 'web_socket_dialog_msgs';
|
||||
if ($params['sender_id'] > 0) {
|
||||
$query->where('web_socket_dialog_msgs.userid', $params['sender_id']);
|
||||
}
|
||||
if ($params['cursor'] > 0) {
|
||||
$query->where('web_socket_dialog_msgs.id', '<', $params['cursor']);
|
||||
$query->where("{$messageTable}.userid", $params['sender_id']);
|
||||
}
|
||||
|
||||
if ($params['file_type'] !== 'all') {
|
||||
$messageTable = DB::getTablePrefix() . 'web_socket_dialog_msgs';
|
||||
$extension = "LOWER(JSON_UNQUOTE(JSON_EXTRACT({$messageTable}.msg, '$.ext')))";
|
||||
$extension = $indexed
|
||||
? 'web_socket_dialog_msg_attachments.ext'
|
||||
: DB::raw("LOWER(JSON_UNQUOTE(JSON_EXTRACT(" . DB::getTablePrefix() . "web_socket_dialog_msgs.msg, '$.ext')))");
|
||||
if ($params['file_type'] === 'other') {
|
||||
$known = array_values(array_unique(array_merge(...array_values(self::FILE_TYPE_EXTENSIONS))));
|
||||
$query->whereNotIn(DB::raw($extension), $known);
|
||||
$query->whereNotIn($extension, $known);
|
||||
} else {
|
||||
$query->whereIn(DB::raw($extension), self::FILE_TYPE_EXTENSIONS[$params['file_type']]);
|
||||
$query->whereIn($extension, self::FILE_TYPE_EXTENSIONS[$params['file_type']]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($params['key'] !== '') {
|
||||
$key = '%' . addcslashes($params['key'], '%_\\') . '%';
|
||||
$query->where(function (Builder $search) use ($key) {
|
||||
$search->where('web_socket_dialog_msgs.key', 'like', $key)
|
||||
$query->where(function (Builder $search) use ($key, $indexed, $messageTable) {
|
||||
$search->where($indexed ? 'web_socket_dialog_msg_attachments.name' : "{$messageTable}.key", 'like', $key);
|
||||
if ($indexed) {
|
||||
$search->orWhere("{$messageTable}.key", 'like', $key);
|
||||
}
|
||||
$search
|
||||
->orWhere('dialogs.name', 'like', $key)
|
||||
->orWhere('project_chat.name', 'like', $key)
|
||||
->orWhere('project_task.name', 'like', $key)
|
||||
->orWhere('task_project.name', 'like', $key)
|
||||
->orWhereExists(function (QueryBuilder $sender) use ($key) {
|
||||
->orWhereExists(function (QueryBuilder $sender) use ($key, $messageTable) {
|
||||
$sender->selectRaw('1')
|
||||
->from('users as search_sender')
|
||||
->whereColumn('search_sender.userid', 'web_socket_dialog_msgs.userid')
|
||||
->whereColumn('search_sender.userid', "{$messageTable}.userid")
|
||||
->where('search_sender.nickname', 'like', $key);
|
||||
})->orWhereExists(function (QueryBuilder $privateUser) use ($key) {
|
||||
$privateUser->selectRaw('1')
|
||||
@ -277,6 +408,86 @@ class CollaborationFileService
|
||||
}
|
||||
}
|
||||
|
||||
private static function attachmentIndexReady(): bool
|
||||
{
|
||||
try {
|
||||
return WebSocketDialogMsgAttachmentBackfill::whereStatus(
|
||||
WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED
|
||||
)->exists();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function cursorSource(string $cursor): ?string
|
||||
{
|
||||
if ($cursor === '' || $cursor === '0') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^m:\d+$/', $cursor) || ctype_digit($cursor)) {
|
||||
return self::CURSOR_SOURCE_MESSAGES;
|
||||
}
|
||||
if (preg_match('/^a:\d+:\d+:\d+$/', $cursor)
|
||||
|| preg_match('/^\d+:\d+:\d+$/', $cursor)) {
|
||||
return self::CURSOR_SOURCE_ATTACHMENTS;
|
||||
}
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
|
||||
private static function messageCursor(string $cursor): int
|
||||
{
|
||||
if ($cursor === '' || $cursor === '0') {
|
||||
return 0;
|
||||
}
|
||||
if (preg_match('/^m:(\d+)$/', $cursor, $matches)) {
|
||||
return intval($matches[1]);
|
||||
}
|
||||
if (ctype_digit($cursor)) {
|
||||
return intval($cursor);
|
||||
}
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
|
||||
private static function applyAttachmentCursor(Builder $query, string $cursor): void
|
||||
{
|
||||
if ($cursor === '' || $cursor === '0') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preg_match('/^a:(\d+):(\d+):(\d+)$/', $cursor, $matches)
|
||||
|| preg_match('/^(\d+):(\d+):(\d+)$/', $cursor, $matches)) {
|
||||
$msgId = intval($matches[1]);
|
||||
$position = intval($matches[2]);
|
||||
$attachmentId = intval($matches[3]);
|
||||
} else {
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
|
||||
$query->where(function (Builder $page) use ($msgId, $position, $attachmentId) {
|
||||
$page->where('web_socket_dialog_msg_attachments.cursor_msg_id', '>', -$msgId)
|
||||
->orWhere(function (Builder $sameMessage) use ($msgId, $position, $attachmentId) {
|
||||
$sameMessage->where('web_socket_dialog_msg_attachments.cursor_msg_id', -$msgId)
|
||||
->where(function (Builder $afterAttachment) use ($position, $attachmentId) {
|
||||
$afterAttachment->where('web_socket_dialog_msg_attachments.position', '>', $position)
|
||||
->orWhere(function (Builder $samePosition) use ($position, $attachmentId) {
|
||||
$samePosition->where('web_socket_dialog_msg_attachments.position', $position)
|
||||
->where('web_socket_dialog_msg_attachments.id', '>', $attachmentId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static function attachmentCursor(WebSocketDialogMsgAttachment $attachment): string
|
||||
{
|
||||
return implode(':', [
|
||||
'a',
|
||||
intval($attachment->msg_id),
|
||||
intval($attachment->position),
|
||||
intval($attachment->id),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function applyConversationType(Builder $query): void
|
||||
{
|
||||
$query->where(function (Builder $type) {
|
||||
@ -396,6 +607,10 @@ class CollaborationFileService
|
||||
}
|
||||
|
||||
return [
|
||||
'attachment_id' => 0,
|
||||
'attachment_source' => WebSocketDialogMsgAttachment::SOURCE_FILE_MESSAGE,
|
||||
'attachment_position' => 0,
|
||||
'generated_name' => false,
|
||||
'msg_id' => intval($row->id),
|
||||
'dialog_id' => intval($row->dialog_id),
|
||||
'name' => (string)($file['name'] ?? ''),
|
||||
@ -418,6 +633,76 @@ class CollaborationFileService
|
||||
];
|
||||
}
|
||||
|
||||
private static function formatAttachmentRow(
|
||||
WebSocketDialogMsgAttachment $row,
|
||||
?User $sender,
|
||||
array $privateNames,
|
||||
User $currentUser
|
||||
): array {
|
||||
$ext = strtolower((string)$row->ext);
|
||||
$name = trim((string)$row->name);
|
||||
$generatedName = $name === '';
|
||||
if ($name === '') {
|
||||
$path = (string)(parse_url((string)$row->path, PHP_URL_PATH) ?: $row->path);
|
||||
$name = basename($path) ?: "image-{$row->msg_id}-" . (intval($row->position) + 1) . ($ext ? ".{$ext}" : '');
|
||||
}
|
||||
|
||||
$imageUrl = '';
|
||||
if ($row->kind === WebSocketDialogMsgAttachment::KIND_IMAGE) {
|
||||
$imageUrl = Base::fillUrl($row->thumb ?: $row->path);
|
||||
}
|
||||
|
||||
$sourceType = 'group';
|
||||
$sourceName = $row->source_dialog_name;
|
||||
$projectId = 0;
|
||||
$projectName = '';
|
||||
$taskId = 0;
|
||||
$taskName = '';
|
||||
|
||||
if ($row->source_dialog_type === 'user') {
|
||||
$sourceType = 'private';
|
||||
$sourceName = $privateNames[intval($row->dialog_id)] ?? $currentUser->nickname;
|
||||
} elseif ($row->source_group_type === 'project') {
|
||||
$sourceType = 'project_chat';
|
||||
$sourceName = $row->project_chat_name ?: $row->source_dialog_name;
|
||||
$projectId = intval($row->project_chat_id);
|
||||
$projectName = (string)$row->project_chat_name;
|
||||
} elseif ($row->source_group_type === 'task') {
|
||||
$sourceType = 'task';
|
||||
$sourceName = $row->source_task_name ?: $row->source_dialog_name;
|
||||
$projectId = intval($row->task_project_id);
|
||||
$projectName = (string)$row->task_project_name;
|
||||
$taskId = intval($row->source_task_id);
|
||||
$taskName = (string)$row->source_task_name;
|
||||
}
|
||||
|
||||
return [
|
||||
'attachment_id' => intval($row->id),
|
||||
'attachment_source' => (string)$row->source_type,
|
||||
'attachment_position' => intval($row->position),
|
||||
'generated_name' => $generatedName,
|
||||
'msg_id' => intval($row->msg_id),
|
||||
'dialog_id' => intval($row->dialog_id),
|
||||
'name' => $name,
|
||||
'ext' => $ext,
|
||||
'size' => intval($row->size),
|
||||
'thumb' => Base::fillUrl($row->thumb ?: Base::extIcon($ext)),
|
||||
'image_url' => $imageUrl,
|
||||
'width' => intval($row->width),
|
||||
'height' => intval($row->height),
|
||||
'file_type' => self::fileType($ext),
|
||||
'source_type' => $sourceType,
|
||||
'source_name' => (string)$sourceName,
|
||||
'project_id' => $projectId,
|
||||
'project_name' => $projectName,
|
||||
'task_id' => $taskId,
|
||||
'task_name' => $taskName,
|
||||
'task_status' => $taskId > 0 ? ($row->source_task_archived_at ? 'archived' : ($row->source_task_complete_at ? 'completed' : 'active')) : '',
|
||||
'sender' => $sender ? $sender->toArray() : ['userid' => intval($row->sender_id)],
|
||||
'created_at' => (string)$row->message_created_at,
|
||||
];
|
||||
}
|
||||
|
||||
private static function fileType(string $ext): string
|
||||
{
|
||||
foreach (self::FILE_TYPE_EXTENSIONS as $type => $extensions) {
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('web_socket_dialog_msg_attachments', function (Blueprint $table) {
|
||||
// MariaDB 10.7 ignores DESC index direction, so negate msg_id to preserve mixed ordering.
|
||||
$table->bigInteger('cursor_msg_id')->storedAs('-`msg_id`');
|
||||
$table->index(['cursor_msg_id', 'position', 'id'], 'idx_ws_msg_attachment_cursor');
|
||||
$table->index(['ext', 'msg_id'], 'idx_ws_msg_attachment_ext_msg');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$hasCursorColumn = Schema::hasColumn('web_socket_dialog_msg_attachments', 'cursor_msg_id');
|
||||
Schema::table('web_socket_dialog_msg_attachments', function (Blueprint $table) use ($hasCursorColumn) {
|
||||
$table->dropIndex('idx_ws_msg_attachment_cursor');
|
||||
$table->dropIndex('idx_ws_msg_attachment_ext_msg');
|
||||
if ($hasCursorColumn) {
|
||||
$table->dropColumn('cursor_msg_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -2690,3 +2690,4 @@ AI 助手设置
|
||||
没有找到相关文件
|
||||
协作文件
|
||||
搜索名称、会话、项目或任务
|
||||
聊天图片 (*)
|
||||
|
||||
@ -38746,5 +38746,17 @@
|
||||
"fr": "Vous n’avez pas l’autorisation d’accéder à ce fichier",
|
||||
"id": "Anda tidak memiliki izin untuk mengakses file ini",
|
||||
"ru": "У вас нет доступа к этому файлу"
|
||||
},
|
||||
{
|
||||
"key": "聊天图片 (%T1)",
|
||||
"zh": "",
|
||||
"zh-CHT": "聊天圖片 (%T1)",
|
||||
"en": "Chat image (%T1)",
|
||||
"ko": "채팅 이미지 (%T1)",
|
||||
"ja": "チャット画像 (%T1)",
|
||||
"de": "Chatbild (%T1)",
|
||||
"fr": "Image de discussion (%T1)",
|
||||
"id": "Gambar obrolan (%T1)",
|
||||
"ru": "Изображение из чата (%T1)"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
2
public/language/web/de.js
vendored
2
public/language/web/de.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/en.js
vendored
2
public/language/web/en.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/fr.js
vendored
2
public/language/web/fr.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/id.js
vendored
2
public/language/web/id.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ja.js
vendored
2
public/language/web/ja.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/key.js
vendored
2
public/language/web/key.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ko.js
vendored
2
public/language/web/ko.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ru.js
vendored
2
public/language/web/ru.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh-CHT.js
vendored
2
public/language/web/zh-CHT.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh.js
vendored
2
public/language/web/zh.js
vendored
File diff suppressed because one or more lines are too long
@ -15,7 +15,7 @@ related_pages: [file]
|
||||
prerequisites:
|
||||
- 当前用户对文件来源的会话、项目或任务有查看权限
|
||||
negative:
|
||||
- 协作文件只汇总聊天中以文件消息发送的附件,不包含以文本链接分享的个人文件
|
||||
- 协作文件不包含以文本链接分享的个人文件,也不把聊天表情计入图片附件
|
||||
- 协作文件中不能新建、上传、移动或删除文件,需回到原会话或对应业务页面操作
|
||||
last_verified: v1.8.89
|
||||
---
|
||||
@ -32,11 +32,11 @@ last_verified: v1.8.89
|
||||
4. 可继续按文件类型、所有成员或我发送的内容筛选,也可搜索文件名、会话名、项目名、任务名或发送人。
|
||||
5. 点击文件预览;点击来源可打开对应聊天或任务;下载按钮直接下载原文件。
|
||||
|
||||
图片文件会在列表和宫格中直接显示缩略图;缩略图不可用时显示对应的文件类型图标。
|
||||
文件消息和粘贴、插入到聊天正文中的图片都会汇总到协作文件。一条聊天消息包含多张图片时,每张图片会作为一条独立记录展示。图片会在列表和宫格中直接显示缩略图;缩略图不可用时显示对应的文件类型图标。
|
||||
|
||||
## 权限与范围
|
||||
- 只展示当前用户仍有权访问的来源;退出会话、项目或任务权限变化后,对应文件不再显示。
|
||||
- 已撤回或删除的文件消息不显示。
|
||||
- 已撤回或删除的文件消息、正文图片不显示;编辑消息移除图片后,对应图片也不再显示。
|
||||
- 任务文件遵循任务可见性:项目成员可看全员可见任务,项目负责人、任务成员和指定可见成员按原任务权限查看。
|
||||
|
||||
## 不支持
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
<span>{{$L('大小')}}</span>
|
||||
<span>{{$L('操作')}}</span>
|
||||
</div>
|
||||
<div v-for="item in items" :key="item.msg_id" class="table-row">
|
||||
<div v-for="item in items" :key="itemKey(item)" class="table-row">
|
||||
<div class="file-main" @click="preview(item)">
|
||||
<div class="collaboration-file-preview">
|
||||
<img
|
||||
@ -85,7 +85,7 @@
|
||||
<div v-else :class="['no-dark-content', 'collaboration-file-icon', fileIconType(item)]"></div>
|
||||
</div>
|
||||
<div class="file-text">
|
||||
<AutoTip class="file-title">{{item.name}}</AutoTip>
|
||||
<AutoTip class="file-title">{{fileName(item)}}</AutoTip>
|
||||
<span>{{fileTypeText(item)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -106,7 +106,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="collaboration-grid">
|
||||
<div v-for="item in items" :key="item.msg_id" class="grid-item" @click="preview(item)">
|
||||
<div v-for="item in items" :key="itemKey(item)" class="grid-item" @click="preview(item)">
|
||||
<div class="grid-preview">
|
||||
<img
|
||||
v-if="showThumbnail(item)"
|
||||
@ -116,7 +116,7 @@
|
||||
@error.stop="handleThumbnailError(item)"/>
|
||||
<div v-else :class="['no-dark-content', 'collaboration-file-icon', fileIconType(item)]"></div>
|
||||
</div>
|
||||
<AutoTip class="grid-title">{{item.name}}</AutoTip>
|
||||
<AutoTip class="grid-title">{{fileName(item)}}</AutoTip>
|
||||
<div class="grid-source">
|
||||
<span :class="['source-type', item.source_type]">{{sourceTypeText(item)}}</span>
|
||||
<AutoTip class="grid-source-name" :content="sourcePath(item)">{{item.source_name}}</AutoTip>
|
||||
@ -250,7 +250,7 @@ export default {
|
||||
this.reload();
|
||||
},
|
||||
load() {
|
||||
if (this.loading || (this.cursor > 0 && !this.hasMore)) return;
|
||||
if (this.loading || (this.cursor && !this.hasMore)) return;
|
||||
const requestId = ++this.requestId;
|
||||
this.loading++;
|
||||
this.$store.dispatch('call', {
|
||||
@ -294,6 +294,9 @@ export default {
|
||||
if (item.file_type === 'archive') return 'archive';
|
||||
return 'file';
|
||||
},
|
||||
itemKey(item) {
|
||||
return item.attachment_id ? `attachment-${item.attachment_id}` : `message-${item.msg_id}`;
|
||||
},
|
||||
showThumbnail(item) {
|
||||
return !!item.image_url && !item._thumbnailError;
|
||||
},
|
||||
@ -312,6 +315,13 @@ export default {
|
||||
};
|
||||
return `${this.$L(labels[item.file_type] || '其他')} · ${(item.ext || '').toUpperCase()}`;
|
||||
},
|
||||
fileName(item) {
|
||||
if (item.attachment_source === 'inline_image' && item.generated_name) {
|
||||
const time = $A.dayjs(item.created_at).format('YYYY-MM-DD HH:mm');
|
||||
return this.$L('聊天图片 (*)', `${time} #${(item.attachment_position || 0) + 1}`);
|
||||
}
|
||||
return item.name;
|
||||
},
|
||||
sourceTypeText(item) {
|
||||
const labels = {
|
||||
private: '私聊',
|
||||
@ -334,6 +344,10 @@ export default {
|
||||
return value.format('YYYY-MM-DD HH:mm');
|
||||
},
|
||||
preview(item) {
|
||||
if (item.attachment_source === 'inline_image' && item.image_url) {
|
||||
this.$store.dispatch('previewImage', item.image_url);
|
||||
return;
|
||||
}
|
||||
openFileInClient(this, item, {
|
||||
path: `/single/file/msg/${item.msg_id}`,
|
||||
windowName: `file-msg-${item.msg_id}`,
|
||||
@ -353,12 +367,15 @@ export default {
|
||||
}).catch(({msg}) => msg && $A.modalError(msg));
|
||||
},
|
||||
download(item) {
|
||||
const url = item.attachment_id
|
||||
? `file/collaboration/download?attachment_id=${item.attachment_id}`
|
||||
: `dialog/msg/download?msg_id=${item.msg_id}`;
|
||||
$A.modalConfirm({
|
||||
language: false,
|
||||
title: this.$L('下载文件'),
|
||||
okText: this.$L('立即下载'),
|
||||
content: `${item.name} (${$A.bytesToSize(item.size)})`,
|
||||
onOk: () => this.$store.dispatch('downUrl', $A.apiUrl(`dialog/msg/download?msg_id=${item.msg_id}`)),
|
||||
content: `${this.fileName(item)} (${$A.bytesToSize(item.size)})`,
|
||||
onOk: () => this.$store.dispatch('downUrl', $A.apiUrl(url)),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
|
||||
|
||||
接口总数:315
|
||||
接口总数:316
|
||||
|
||||
## 路由规则
|
||||
|
||||
@ -303,6 +303,7 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方
|
||||
| URL | 方法名 | HTTP | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| api/file/collaboration/lists | collaboration__lists() | get | 获取协作文件列表 |
|
||||
| api/file/collaboration/download | collaboration__download() | get | 下载协作文件附件 |
|
||||
| api/file/lists | lists() | get | 获取文件列表 |
|
||||
| api/file/one | one() | get | 获取单条数据 |
|
||||
| api/file/fetch | fetch() | get | 通过路径获取文件文本内容 |
|
||||
|
||||
@ -9,9 +9,12 @@ use App\Models\ProjectUser;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogMsgAttachment;
|
||||
use App\Models\WebSocketDialogMsgAttachmentBackfill;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Services\CollaborationFileService;
|
||||
use App\Services\MessageAttachmentService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\TestCase;
|
||||
|
||||
@ -19,6 +22,15 @@ class CollaborationFileServiceTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
WebSocketDialogMsgAttachmentBackfill::query()->update([
|
||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeUser(string $email): User
|
||||
{
|
||||
$user = User::createInstance([
|
||||
@ -64,6 +76,7 @@ class CollaborationFileServiceTest extends TestCase
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$message->save();
|
||||
MessageAttachmentService::sync($message);
|
||||
return $message;
|
||||
}
|
||||
|
||||
@ -186,10 +199,11 @@ class CollaborationFileServiceTest extends TestCase
|
||||
$this->makeFile($dialog, $viewer, 'deleted.pdf')->delete();
|
||||
$last = $this->makeFile($dialog, $viewer, 'last.png');
|
||||
|
||||
$page = CollaborationFileService::lists($viewer, ['take' => 1]);
|
||||
// The browser sends cursor=0 on the initial request.
|
||||
$page = CollaborationFileService::lists($viewer, ['take' => 1, 'cursor' => '0']);
|
||||
$this->assertSame(['last.png'], array_column($page['list'], 'name'));
|
||||
$this->assertTrue($page['has_more']);
|
||||
$this->assertSame($last->id, $page['next_cursor']);
|
||||
$this->assertStringStartsWith("a:{$last->id}:0:", $page['next_cursor']);
|
||||
|
||||
$next = CollaborationFileService::lists($viewer, [
|
||||
'cursor' => $page['next_cursor'],
|
||||
@ -199,16 +213,137 @@ class CollaborationFileServiceTest extends TestCase
|
||||
$this->assertFalse($next['has_more']);
|
||||
}
|
||||
|
||||
public function test_authorize_message_rejects_non_member(): void
|
||||
public function test_lists_paginates_each_inline_image_in_one_message(): void
|
||||
{
|
||||
$viewer = $this->makeUser('collaboration-inline@test.local');
|
||||
$dialog = $this->makeDialog('user', '', '', [$viewer->userid]);
|
||||
$message = WebSocketDialogMsg::createInstance([
|
||||
'dialog_id' => $dialog->id,
|
||||
'userid' => $viewer->userid,
|
||||
'type' => 'text',
|
||||
'key' => '',
|
||||
'msg' => json_encode([
|
||||
'text' => '<p><img class="browse" src="uploads/test/first.png" alt="第一张"/>'
|
||||
. '<img class="browse" src="uploads/test/second.png" alt="第二张"/></p>',
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$message->save();
|
||||
MessageAttachmentService::sync($message);
|
||||
|
||||
$firstPage = CollaborationFileService::lists($viewer, ['take' => 1]);
|
||||
$this->assertSame(['第一张'], array_column($firstPage['list'], 'name'));
|
||||
$this->assertSame('inline_image', $firstPage['list'][0]['attachment_source']);
|
||||
$this->assertSame($message->id, $firstPage['list'][0]['msg_id']);
|
||||
$this->assertTrue($firstPage['has_more']);
|
||||
|
||||
$secondPage = CollaborationFileService::lists($viewer, [
|
||||
'take' => 1,
|
||||
'cursor' => $firstPage['next_cursor'],
|
||||
]);
|
||||
$this->assertSame(['第二张'], array_column($secondPage['list'], 'name'));
|
||||
$this->assertSame($message->id, $secondPage['list'][0]['msg_id']);
|
||||
$this->assertNotSame($firstPage['list'][0]['attachment_id'], $secondPage['list'][0]['attachment_id']);
|
||||
$this->assertFalse($secondPage['has_more']);
|
||||
|
||||
$legacySecondPage = CollaborationFileService::lists($viewer, [
|
||||
'take' => 1,
|
||||
'cursor' => substr($firstPage['next_cursor'], 2),
|
||||
]);
|
||||
$this->assertSame(['第二张'], array_column($legacySecondPage['list'], 'name'));
|
||||
}
|
||||
|
||||
public function test_lists_falls_back_to_messages_until_index_is_ready(): void
|
||||
{
|
||||
$viewer = $this->makeUser('collaboration-fallback@test.local');
|
||||
$dialog = $this->makeDialog('group', 'user', '回填中群聊', [$viewer->userid]);
|
||||
$message = $this->makeFile($dialog, $viewer, 'fallback.pdf');
|
||||
WebSocketDialogMsgAttachment::whereMsgId($message->id)->delete();
|
||||
WebSocketDialogMsgAttachmentBackfill::query()->update([
|
||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_PENDING,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
|
||||
$result = CollaborationFileService::lists($viewer, []);
|
||||
|
||||
$this->assertSame(['fallback.pdf'], array_column($result['list'], 'name'));
|
||||
$this->assertSame(0, $result['list'][0]['attachment_id']);
|
||||
$this->assertSame('file_message', $result['list'][0]['attachment_source']);
|
||||
}
|
||||
|
||||
public function test_message_cursor_keeps_fallback_source_after_backfill_completes(): void
|
||||
{
|
||||
$viewer = $this->makeUser('collaboration-transition@test.local');
|
||||
$dialog = $this->makeDialog('group', 'user', '回填切换群聊', [$viewer->userid]);
|
||||
$older = $this->makeFile($dialog, $viewer, 'older.pdf');
|
||||
$newer = $this->makeFile($dialog, $viewer, 'newer.pdf');
|
||||
WebSocketDialogMsgAttachmentBackfill::query()->update([
|
||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_PENDING,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
|
||||
$firstPage = CollaborationFileService::lists($viewer, ['take' => 1, 'cursor' => '0']);
|
||||
$this->assertSame([$newer->id], array_column($firstPage['list'], 'msg_id'));
|
||||
$this->assertSame("m:{$newer->id}", $firstPage['next_cursor']);
|
||||
|
||||
WebSocketDialogMsgAttachmentBackfill::query()->update([
|
||||
'status' => WebSocketDialogMsgAttachmentBackfill::STATUS_COMPLETED,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$secondPage = CollaborationFileService::lists($viewer, [
|
||||
'take' => 1,
|
||||
'cursor' => $firstPage['next_cursor'],
|
||||
]);
|
||||
|
||||
$this->assertSame([$older->id], array_column($secondPage['list'], 'msg_id'));
|
||||
$this->assertSame(0, $secondPage['list'][0]['attachment_id']);
|
||||
|
||||
$legacySecondPage = CollaborationFileService::lists($viewer, [
|
||||
'take' => 1,
|
||||
'cursor' => (string)$newer->id,
|
||||
]);
|
||||
$this->assertSame([$older->id], array_column($legacySecondPage['list'], 'msg_id'));
|
||||
$this->assertSame(0, $legacySecondPage['list'][0]['attachment_id']);
|
||||
}
|
||||
|
||||
public function test_resolve_local_attachment_path_rejects_traversal(): void
|
||||
{
|
||||
$directory = public_path('uploads/tmp/collaboration-file-test');
|
||||
$file = $directory . '/download.txt';
|
||||
$link = $directory . '/escape.env';
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0775, true);
|
||||
}
|
||||
file_put_contents($file, 'download');
|
||||
symlink(base_path('.env'), $link);
|
||||
|
||||
try {
|
||||
$this->assertSame(realpath($file), CollaborationFileService::resolveLocalAttachmentPath(
|
||||
'uploads/tmp/collaboration-file-test/download.txt'
|
||||
));
|
||||
$this->assertNull(CollaborationFileService::resolveLocalAttachmentPath('../.env'));
|
||||
$this->assertNull(CollaborationFileService::resolveLocalAttachmentPath('uploads/../index.php'));
|
||||
$this->assertNull(CollaborationFileService::resolveLocalAttachmentPath(
|
||||
'uploads/tmp/collaboration-file-test/escape.env'
|
||||
));
|
||||
$this->assertNull(CollaborationFileService::resolveLocalAttachmentPath('https://example.com/file.pdf'));
|
||||
} finally {
|
||||
@unlink($link);
|
||||
@unlink($file);
|
||||
@rmdir($directory);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_authorize_attachment_rejects_non_member(): void
|
||||
{
|
||||
$member = $this->makeUser('collaboration-member@test.local');
|
||||
$outsider = $this->makeUser('collaboration-denied@test.local');
|
||||
$dialog = $this->makeDialog('group', 'user', '权限群', [$member->userid]);
|
||||
$message = $this->makeFile($dialog, $member, 'permission.pdf');
|
||||
$attachment = WebSocketDialogMsgAttachment::whereMsgId($message->id)->firstOrFail();
|
||||
|
||||
CollaborationFileService::authorizeMessage($message, $member);
|
||||
CollaborationFileService::authorizeAttachment($attachment, $member);
|
||||
$this->expectException(ApiException::class);
|
||||
$this->expectExceptionMessage('无权限访问此文件');
|
||||
CollaborationFileService::authorizeMessage($message, $outsider);
|
||||
CollaborationFileService::authorizeAttachment($attachment, $outsider);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user