feat(file): switch collaboration files to attachment index

This commit is contained in:
kuaifan 2026-08-09 15:08:24 +00:00
parent 380bfaaea6
commit fd37fcf6f6
19 changed files with 562 additions and 45 deletions

View File

@ -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 获取文件列表
*

View File

@ -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) {

View File

@ -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');
}
});
}
};

View File

@ -2690,3 +2690,4 @@ AI 助手设置
没有找到相关文件
协作文件
搜索名称、会话、项目或任务
聊天图片 (*)

View File

@ -38746,5 +38746,17 @@
"fr": "Vous navez pas lautorisation daccé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)"
}
]
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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. 点击文件预览;点击来源可打开对应聊天或任务;下载按钮直接下载原文件。
图片文件会在列表和宫格中直接显示缩略图;缩略图不可用时显示对应的文件类型图标。
文件消息和粘贴、插入到聊天正文中的图片都会汇总到协作文件。一条聊天消息包含多张图片时,每张图片会作为一条独立记录展示。图片会在列表和宫格中直接显示缩略图;缩略图不可用时显示对应的文件类型图标。
## 权限与范围
- 只展示当前用户仍有权访问的来源;退出会话、项目或任务权限变化后,对应文件不再显示。
- 已撤回或删除的文件消息不显示。
- 已撤回或删除的文件消息、正文图片显示;编辑消息移除图片后,对应图片也不再显示。
- 任务文件遵循任务可见性:项目成员可看全员可见任务,项目负责人、任务成员和指定可见成员按原任务权限查看。
## 不支持

View File

@ -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)),
});
},
},

View File

@ -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 | 通过路径获取文件文本内容 |

View File

@ -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);
}
}