diff --git a/packages/global/core/dataset/constants.ts b/packages/global/core/dataset/constants.ts index 53bf14168d5e..3987ecaf895e 100644 --- a/packages/global/core/dataset/constants.ts +++ b/packages/global/core/dataset/constants.ts @@ -2,13 +2,33 @@ import { i18nT } from '../../common/i18n/utils'; /* ------------ dataset -------------- */ /** - * 知识库训练审计事件;持久化复用 support/user/audit 的通用审计框架。 + * 知识库审计事件(集合 + 训练);持久化复用 support/user/audit 的通用审计框架。 * - * value 采用与集合审计一致的点分小写路径:前半段是最多三级资源层级,最后一段是动词原形动作 - * (retry / delete / rebuild);成员名保持仓库惯例的大写下划线。 + * value 采用 OpenAI Audit Logs 风格的点分小写路径:前半段是资源层级 + * (`dataset.collection` / `dataset.training` / `dataset.index`),最后一段是动词原形动作 + * (create / update / sync / export / add / remove / retry / delete / rebuild); + * 成员名保持仓库惯例的大写下划线。 * 值直接进入 operationLogs.event,修改会影响历史日志展示。 */ export enum DatasetAuditEventEnum { + /** 创建集合;导入文件按「一个文件一条日志」逐条写入。 */ + DATASET_COLLECTION_CREATE = 'dataset.collection.create', + /** 更新集合对象属性;一次请求命中多项变更时按变更逐条写入。 */ + DATASET_COLLECTION_UPDATE = 'dataset.collection.update', + DATASET_COLLECTION_EXPORT = 'dataset.collection.export', + /** 同步集合:只记录发起的同步操作,不区分同步结果。 */ + DATASET_COLLECTION_SYNC = 'dataset.collection.sync', + DATASET_COLLECTION_TAG_ADD = 'dataset.collection.tag.add', + DATASET_COLLECTION_TAG_REMOVE = 'dataset.collection.tag.remove', + /** + * 集合权限恢复为继承上级。 + * + * 集合的权限继承与知识库 / 应用不同:它既可以继承父目录,也可以直接继承知识库; + * 且只有知识库启用 collection 级权限后才存在「独立态」。因此单独建一个事件, + * 不和 DATASET_COLLECTION_UPDATE 的子动作混在一起——它不改集合的可编辑属性, + * 只重置 ACL 快照,审计展示上需要单列一栏。 + */ + DATASET_COLLECTION_PERMISSION_INHERITANCE_RESUME = 'dataset.collection.permission.inheritance_resume', /** 知识库级全部重试:不指定集合,重试该知识库下所有失败训练任务。 */ DATASET_TRAINING_RETRY = 'dataset.training.retry', /** 集合级全部重试:重试指定集合下的所有失败训练任务。 */ @@ -21,6 +41,16 @@ export enum DatasetAuditEventEnum { DATASET_INDEX_REBUILD = 'dataset.index.rebuild' } +/** 集合对象的可审计变更动作,区分同一次更新中的改名 / 移动 / 启停。 */ +export enum DatasetContentAuditActionEnum { + RENAME = 'rename', + MOVE = 'move', + ENABLE = 'enable', + DISABLE = 'disable' +} + +export type DatasetContentAuditAction = `${DatasetContentAuditActionEnum}`; + export enum DatasetTypeEnum { folder = 'folder', dataset = 'dataset', diff --git a/packages/global/support/user/audit/constants.ts b/packages/global/support/user/audit/constants.ts index 87e11575fd82..4308508ec0d5 100644 --- a/packages/global/support/user/audit/constants.ts +++ b/packages/global/support/user/audit/constants.ts @@ -76,7 +76,9 @@ export enum AuditEventEnum { EXPORT_DATASET = 'EXPORT_DATASET', CREATE_DATASET_FOLDER = 'CREATE_DATASET_FOLDER', //Collection + /** @deprecated 创建集合已统一由 DatasetAuditEventEnum.DATASET_COLLECTION_CREATE 记录,保留枚举仅为兼容历史日志展示。 */ CREATE_COLLECTION = 'CREATE_COLLECTION', + /** @deprecated 集合更新已统一由 DatasetAuditEventEnum.DATASET_COLLECTION_UPDATE 记录,保留枚举仅为兼容历史日志展示。 */ UPDATE_COLLECTION = 'UPDATE_COLLECTION', DELETE_COLLECTION = 'DELETE_COLLECTION', RETRAIN_COLLECTION = 'RETRAIN_COLLECTION', @@ -126,7 +128,7 @@ export type AdminAuditEventParamsType = { [K in AdminAuditEventEnum]: (typeof adminAuditLogMap)[K]['params']; }; -/** 允许以 system 作用域写入的知识库审计事件(无操作人成员 ID)。 */ +/** 允许以 system 作用域写入的知识库审计事件(集合 + 训练,无操作人成员 ID)。 */ export type DatasetAuditEvent = `${DatasetAuditEventEnum}`; export type DatasetAuditEventParamsType = { @@ -134,7 +136,7 @@ export type DatasetAuditEventParamsType = { }; /** - * 团队审计事件全集:成员事件 + 知识库事件。 + * 团队审计事件全集:成员事件 + 知识库事件(集合 + 训练)。 * * Mongo enum、Zod 校验、共享类型与前端筛选菜单都从这里派生,避免各个消费方 * 各自维护一份事件列表而错配(曾导致知识库事件能写入、却读不出)。 diff --git a/packages/global/test/openapi/support/user/api.test.ts b/packages/global/test/openapi/support/user/api.test.ts index b52788492144..489b5461088a 100644 --- a/packages/global/test/openapi/support/user/api.test.ts +++ b/packages/global/test/openapi/support/user/api.test.ts @@ -210,11 +210,11 @@ describe('support user OpenAPI contracts', () => { }); it('accepts every dataset audit event in filter and response', () => { - // 知识库事件与成员事件共用同一个审计存储;双方都必须出现在 TeamAuditEventList 里, + // 知识库事件(集合 + 训练)与成员事件共用同一个审计存储;双方都必须出现在 TeamAuditEventList 里, // 否则新事件会「能写入但读不出」——团队审计列表在响应 schema 校验阶段整体失败。 const datasetEvents = Object.values(DatasetAuditEventEnum); - expect(datasetEvents).toHaveLength(5); + expect(datasetEvents).toHaveLength(12); expect(TeamAuditEventList).toEqual(expect.arrayContaining(datasetEvents)); expect(AuditListBodySchema.parse({ events: datasetEvents }).events).toEqual(datasetEvents); diff --git a/packages/service/core/dataset/collection/audit.ts b/packages/service/core/dataset/collection/audit.ts new file mode 100644 index 000000000000..94357bf5f315 --- /dev/null +++ b/packages/service/core/dataset/collection/audit.ts @@ -0,0 +1,25 @@ +import { DatasetCollectionTypeMap } from '@fastgpt/global/core/dataset/constants'; +import { getDatasetCollectionPaths } from './paths'; + +/** 未知集合类型没有展示名,回退原始枚举值,避免审计里出现空对象类型。 */ +export const getCollectionAuditObjectType = (type: string): string => + DatasetCollectionTypeMap[type as keyof typeof DatasetCollectionTypeMap]?.name ?? type; + +/** + * 生成集合审计日志的位置字段(`知识库名/父目录/子目录`),不包含集合自身。 + * + * 位置只服务于日志展示:调用方此时已完成鉴权与写入,取路径失败不能影响主流程, + * 因此这里吞掉查询异常并退回知识库名。 + */ +export const getCollectionAuditLocation = async ({ + datasetName, + parentId +}: { + datasetName: string; + parentId?: string | null; +}): Promise => { + const paths = await getDatasetCollectionPaths({ parentId: parentId ?? undefined }).catch( + () => [] + ); + return [datasetName, ...paths.map((path) => path.parentName)].filter(Boolean).join('/'); +}; diff --git a/packages/service/core/dataset/collection/paths.ts b/packages/service/core/dataset/collection/paths.ts new file mode 100644 index 000000000000..dc171881596c --- /dev/null +++ b/packages/service/core/dataset/collection/paths.ts @@ -0,0 +1,45 @@ +import type { + ParentIdType, + ParentTreePathItemType +} from '@fastgpt/global/common/parentFolder/type'; +import { MongoDatasetCollection } from './schema'; + +/** + * 递归查询集合的祖先目录链(从根到直接父级,不包含集合自身)。 + * + * 递归而非聚合:调用方都已经校验过父目录深度,层级有限;且每一层都命中 `_id` 主键查询。 + * 集合不存在或父级断链时提前返回已收集的部分,不会抛错。 + * + * 带环检测而非信任数据:历史脏数据可能出现父子互指,此时每层都是 `await` 主键查询, + * 不会爆栈而是无限查询下去,必须用 visited 兜底。命中环时保留已收集的链路并停止向上, + * 与「父级断链」语义保持一致——审计位置只用于展示,不能因此让主流程报错。 + */ +export async function getDatasetCollectionPaths({ + parentId = '' +}: { + parentId?: string; +}): Promise { + const visited = new Set(); + + async function find(parentId: ParentIdType): Promise { + if (!parentId) { + return []; + } + // 已访问过说明链路成环,直接截断,避免无限查询 + if (visited.has(String(parentId))) { + return []; + } + visited.add(String(parentId)); + + const parent = await MongoDatasetCollection.findOne({ _id: parentId }, 'name parentId'); + + if (!parent) return []; + + const paths = await find(parent.parentId ?? undefined); + paths.push({ parentId, parentName: parent.name }); + + return paths; + } + + return find(parentId); +} diff --git a/packages/service/support/user/audit/util.ts b/packages/service/support/user/audit/util.ts index f005e0b46005..3123bc62ac5f 100644 --- a/packages/service/support/user/audit/util.ts +++ b/packages/service/support/user/audit/util.ts @@ -98,7 +98,7 @@ export function addAuditLog( input: AddAuditLogInput ): Promise; -/** 知识库事件:事件名取 DatasetAuditEventEnum 的点分 value,参数类型按 DatasetAuditEventParamsType 校验。 */ +/** 知识库事件(集合 + 训练):事件名取 DatasetAuditEventEnum 的点分 value,参数类型按 DatasetAuditEventParamsType 校验。 */ export function addAuditLog( input: AddAuditLogInput ): Promise; diff --git a/packages/web/i18n/en/account_team.json b/packages/web/i18n/en/account_team.json index c574e906ec6a..bdddc4df2787 100644 --- a/packages/web/i18n/en/account_team.json +++ b/packages/web/i18n/en/account_team.json @@ -60,6 +60,18 @@ "dataset_training_task_retry": "Retry single training task", "dataset_training_record_delete": "Delete collection training record", "dataset_index_rebuild": "Rebuild dataset index", + "dataset_collection_created": "Create a collection", + "dataset_collection_updated": "Update collection property", + "dataset_collection_exported": "Export collection content", + "dataset_collection_synced": "Sync collection", + "collection_tag_added": "Add collection tag", + "collection_tag_removed": "Remove collection tag", + "collection_permission_inheritance_resumed": "Restore collection inherited permissions", + "dataset_content_change_item_name": "Name", + "dataset_content_change_item_location": "Location", + "dataset_content_change_item_status": "Enable status", + "dataset_content_status_enabled": "Enabled", + "dataset_content_status_disabled": "Disabled", "create_data": "Insert data", "create_dataset": "Create Dataset", "create_dataset_folder": "Create Dataset folder", @@ -195,6 +207,7 @@ "log_dataset_training_task_retry": "[{{name}}] retried a single training task in collection [{{collectionName}}] of dataset [{{datasetName}}]", "log_dataset_training_record_delete": "[{{name}}] deleted a training record in collection [{{collectionName}}] of dataset [{{datasetName}}]", "log_dataset_index_rebuild": "[{{name}}] changed the index model of dataset [{{datasetName}}] from [{{oldModelName}}] to [{{newModelName}}] and rebuilt the index", + "log_dataset_collection_created": "[{{name}}] created [{{objectType}}] [{{collectionName}}] in [{{location}}].", "log_create_data": "【{{name}}】 inserted data into the collection 【{{collectionName}}】 in the {{datasetType}} 【{{datasetName}}】", "log_create_dataset": "【{{name}}】Created 【{{datasetType}}】 named 【{{datasetName}}】", "log_create_dataset_folder": "【{{name}}】Created a folder named {{folderName}}】", @@ -218,6 +231,7 @@ "log_export_app_chat_log": "【{{name}}】Export a chat history called [{{appName}}] called [{{appType}}]", "log_export_bill_records": "【{{name}}】Export the billing record", "log_export_dataset": "[{{name}}] Export [{{datasetType}}] called [{{datasetName}}]", + "log_dataset_collection_exported": "[{{name}}] exported the content of [{{collectionName}}] in dataset [{{datasetName}}].", "log_export_evaluation": "{{name}} exported evaluation data for {{appType}} named {{appName}}", "log_join_team": "【{{name}}】Join the team through the invitation link 【{{link}}】", "log_kick_out_team": "{{name}} removed member {{memberName}}", @@ -228,6 +242,10 @@ "log_recover_team_member": "【{{name}}】Restored member【{{memberName}}】", "log_relocate_department": "【{{name}}】Displayed department【{{departmentName}}】", "log_retrain_collection": "【{{name}}】 reindexed the Collection 【{{collectionName}}】 in the {{datasetType}} 【{{datasetName}}】", + "log_dataset_collection_synced": "[{{name}}] synced [{{collectionName}}] in dataset [{{datasetName}}].", + "log_collection_tag_added": "[{{name}}] added tag [{{tagName}}] to [{{collectionName}}] in dataset [{{datasetName}}].", + "log_collection_tag_removed": "[{{name}}] removed tag [{{tagName}}] from [{{collectionName}}] in dataset [{{datasetName}}].", + "log_collection_permission_inheritance_resumed": "[{{name}}] restored permissions of [{{collectionName}}] in dataset [{{datasetName}}] to inherit from [{{parentResource}}]", "log_search_test": "【{{name}}】 ran a search test in the {{datasetType}} 【{{datasetName}}】", "log_set_invoice_header": "【{{name}}】The invoice header operation was set up", "log_time": "Operation time", @@ -240,6 +258,7 @@ "log_update_app_info": "[{{name}}] updated [{{appType}}] named [{{appName}}]: [{{newItemNames}}] to [{{newItemValues}}]", "log_update_app_publish_channel": "[{{name}}] Updated a channel named [{{channelName}}] for [{{appType}}] called [{{appName}}].", "log_update_collection": "[{{name}}] Updated a collection named [{{collectionName}}] in [{{datasetType}}] called [{{datasetName}}].", + "log_dataset_collection_updated": "[{{name}}] changed the [{{changeItem}}] of [{{collectionName}}] in dataset [{{datasetName}}] from [{{oldValue}}] to [{{newValue}}].", "log_update_data": "【{{name}}】 updated data in the collection 【{{collectionName}}】 in the {{datasetType}} 【{{datasetName}}】", "log_update_dataset": "【{{name}}】Updated [{{datasetType}}] named [{{datasetName}}]", "log_update_dataset_collaborator": "【{{name}}】 updated collaborators for the {{datasetType}} 【{{datasetName}}】: organizations 【{{orgList}}】, groups 【{{groupList}}】, members 【{{tmbList}}】; permissions: 【{{readPermission}}】, 【{{writePermission}}】, 【{{managePermission}}】", diff --git a/packages/web/i18n/ko-KR/account_team.json b/packages/web/i18n/ko-KR/account_team.json index 368abedb2574..2a52da39a24f 100644 --- a/packages/web/i18n/ko-KR/account_team.json +++ b/packages/web/i18n/ko-KR/account_team.json @@ -56,6 +56,18 @@ "dataset_training_task_retry": "단일 학습 작업 재시도", "dataset_training_record_delete": "컬렉션 학습 기록 삭제", "dataset_index_rebuild": "지식베이스 인덱스 재구축", + "dataset_collection_created": "컬렉션 생성", + "dataset_collection_updated": "컬렉션 속성 업데이트", + "dataset_collection_exported": "컬렉션 콘텐츠 내보내기", + "dataset_collection_synced": "컬렉션 동기화", + "collection_tag_added": "컬렉션 태그 추가", + "collection_tag_removed": "컬렉션 태그 제거", + "collection_permission_inheritance_resumed": "컬렉션 상속 권한 복원", + "dataset_content_change_item_name": "이름", + "dataset_content_change_item_location": "위치", + "dataset_content_change_item_status": "활성화 상태", + "dataset_content_status_enabled": "활성화", + "dataset_content_status_disabled": "비활성화", "create_data": "데이터 삽입", "create_dataset": "데이터셋 생성", "create_dataset_folder": "데이터셋 폴더 생성", @@ -187,6 +199,7 @@ "log_dataset_training_task_retry": "[{{name}}]님이 지식베이스 [{{datasetName}}]의 컬렉션 [{{collectionName}}]에 있는 단일 학습 작업을 재시도함.", "log_dataset_training_record_delete": "[{{name}}]님이 지식베이스 [{{datasetName}}]의 컬렉션 [{{collectionName}}]에 있는 학습 기록을 삭제함.", "log_dataset_index_rebuild": "[{{name}}]님이 지식베이스 [{{datasetName}}]의 인덱스 모델을 [{{oldModelName}}]에서 [{{newModelName}}](으)로 변경하고 인덱스를 재구축함.", + "log_dataset_collection_created": "[{{name}}]님이 [{{location}}]에 [{{objectType}}] [{{collectionName}}]을(를) 생성함.", "log_create_data": "[{{name}}]님이 [{{datasetType}}]의 [{{datasetName}}]이라는 이름의 컬렉션에 [{{collectionName}}]이라는 이름의 컬렉션으로 데이터를 삽입함", "log_create_dataset": "【{{name}}】【{{datasetName}}】라는 이름의 【{{datasetType}}】을(를) 생성함", "log_create_dataset_folder": "【{{name}}】{{folderName}}】이라는 이름의 폴더를 생성함", @@ -210,6 +223,7 @@ "log_export_app_chat_log": "【{{name}}】[{{appType}}]인 [{{appName}}]의 대화 기록을 내보냄", "log_export_bill_records": "【{{name}}】청구 기록을 내보냄", "log_export_dataset": "[{{name}}]님이 [{{datasetName}}]이라는 이름의 [{{datasetType}}]을(를) 내보냄", + "log_dataset_collection_exported": "[{{name}}]님이 지식베이스 [{{datasetName}}]의 [{{collectionName}}] 콘텐츠를 내보냄.", "log_export_evaluation": "{{name}}님이 {{appName}}이라는 이름의 {{appType}}에 대한 평가 데이터를 내보냄", "log_join_team": "【{{name}}】초대 링크【{{link}}】를 통해 팀에 가입함", "log_kick_out_team": "{{name}}님이 멤버 {{memberName}}을(를) 제거함", @@ -220,6 +234,10 @@ "log_recover_team_member": "【{{name}}】멤버【{{memberName}}】을(를) 복구함", "log_relocate_department": "【{{name}}】부서【{{departmentName}}】를 이동함", "log_retrain_collection": "[{{name}}]님이 [{{datasetName}}]이라는 이름의 [{{datasetType}}]에서 [{{collectionName}}]이라는 이름의 컬렉션을 재학습함.", + "log_dataset_collection_synced": "[{{name}}]님이 지식베이스 [{{datasetName}}]의 [{{collectionName}}]을(를) 동기화함.", + "log_collection_tag_added": "[{{name}}]님이 지식베이스 [{{datasetName}}]의 [{{collectionName}}]에 태그 [{{tagName}}]을(를) 추가함.", + "log_collection_tag_removed": "[{{name}}]님이 지식베이스 [{{datasetName}}]의 [{{collectionName}}]에서 태그 [{{tagName}}]을(를) 제거함.", + "log_collection_permission_inheritance_resumed": "[{{name}}]님이 지식베이스 [{{datasetName}}]의 [{{collectionName}}] 권한을 [{{parentResource}}]에서 상속하도록 복원함.", "log_search_test": "【{{name}}】[{{datasetName}}]이라는 이름의 [{{datasetType}}]에 대해 검색 테스트를 수행함", "log_set_invoice_header": "【{{name}}】세금계산서 상호 설정 작업을 수행함", "log_time": "작업 시간", @@ -232,6 +250,7 @@ "log_update_app_info": "[{{name}}]님이 [{{appName}}]이라는 이름의 [{{appType}}]을(를) 업데이트함: [{{newItemNames}}]을(를) [{{newItemValues}}](으)로", "log_update_app_publish_channel": "[{{name}}]님이 [{{appName}}]이라는 이름의 [{{appType}}]에 대해 [{{channelName}}]이라는 이름의 채널을 업데이트함.", "log_update_collection": "[{{name}}]님이 [{{datasetName}}]이라는 이름의 [{{datasetType}}]에서 [{{collectionName}}]이라는 이름의 컬렉션을 업데이트함.", + "log_dataset_collection_updated": "[{{name}}]님이 [{{datasetName}}]의 [{{collectionName}}]에 있는 [{{changeItem}}]을(를) [{{oldValue}}]에서 [{{newValue}}](으)로 변경함.", "log_update_data": "【{{name}}】[{{datasetType}}]의 [{{collectionName}}]으로 【{{datasetName}}】이라는 이름의 컬렉션 내 데이터를 업데이트함", "log_update_dataset": "【{{name}}】[{{datasetName}}]이라는 이름의 [{{datasetType}}]을(를) 업데이트함", "log_update_dataset_collaborator": "【{{name}}】님이 【{{datasetName}}】 {{datasetType}}의 협업자를 조직 【{{orgList}}】, 그룹 【{{groupList}}】, 멤버 【{{tmbList}}】(으)로 변경하고 권한을 【{{readPermission}}】, 【{{writePermission}}】, 【{{managePermission}}】(으)로 변경함", diff --git a/packages/web/i18n/zh-CN/account_team.json b/packages/web/i18n/zh-CN/account_team.json index 2ff59e4517a3..912ec8837b7c 100644 --- a/packages/web/i18n/zh-CN/account_team.json +++ b/packages/web/i18n/zh-CN/account_team.json @@ -60,6 +60,18 @@ "dataset_training_task_retry": "重试单个训练任务", "dataset_training_record_delete": "删除集合训练记录", "dataset_index_rebuild": "重建知识库索引", + "dataset_collection_created": "创建集合", + "dataset_collection_updated": "更新集合属性", + "dataset_collection_exported": "导出集合内容", + "dataset_collection_synced": "同步集合", + "collection_tag_added": "为集合添加标签", + "collection_tag_removed": "移除集合标签", + "collection_permission_inheritance_resumed": "恢复集合继承权限", + "dataset_content_change_item_name": "名称", + "dataset_content_change_item_location": "位置", + "dataset_content_change_item_status": "启用状态", + "dataset_content_status_enabled": "启用", + "dataset_content_status_disabled": "禁用", "create_data": "插入数据", "create_dataset": "创建知识库", "create_dataset_folder": "创建知识库文件夹", @@ -195,6 +207,7 @@ "log_dataset_training_task_retry": "【{{name}}】重试了知识库【{{datasetName}}】中集合【{{collectionName}}】的单个训练任务", "log_dataset_training_record_delete": "【{{name}}】删除了知识库【{{datasetName}}】中集合【{{collectionName}}】的训练记录", "log_dataset_index_rebuild": "【{{name}}】将知识库【{{datasetName}}】的索引模型从【{{oldModelName}}】调整为【{{newModelName}}】,并重建索引", + "log_dataset_collection_created": "【{{name}}】在【{{location}}】中创建了【{{objectType}}】【{{collectionName}}】", "log_create_data": "【{{name}}】在名为【{{datasetName}}】的【{{datasetType}}】往名为【{{collectionName}}】的集合插入数据", "log_create_dataset": "【{{name}}】创建了名为【{{datasetName}}】的【{{datasetType}}】", "log_create_dataset_folder": "【{{name}}】创建了名为{{folderName}}】的文件夹", @@ -218,6 +231,7 @@ "log_export_app_chat_log": "【{{name}}】导出了名为【{{appName}}】的【{{appType}}】的聊天记录", "log_export_bill_records": "【{{name}}】导出了账单记录", "log_export_dataset": "【{{name}}】导出了名为【{{datasetName}}】的【{{datasetType}}】", + "log_dataset_collection_exported": "【{{name}}】导出了【{{datasetName}}】中【{{collectionName}}】的集合内容", "log_export_evaluation": "【{{name}}】导出了名为【{{appName}}】的【{{appType}}】的评测数据", "log_join_team": "【{{name}}】通过邀请链接【{{link}}】加入团队", "log_kick_out_team": "【{{name}}】移除了成员【{{memberName}}】", @@ -228,6 +242,10 @@ "log_recover_team_member": "【{{name}}】恢复了成员【{{memberName}}】", "log_relocate_department": "【{{name}}】移动了部门【{{departmentName}}】", "log_retrain_collection": "【{{name}}】在名为【{{datasetName}}】的【{{datasetType}}】重新训练了名为【{{collectionName}}】的集合", + "log_dataset_collection_synced": "【{{name}}】同步了【{{datasetName}}】中的【{{collectionName}}】", + "log_collection_tag_added": "【{{name}}】为【{{datasetName}}】中的【{{collectionName}}】添加了标签【{{tagName}}】", + "log_collection_tag_removed": "【{{name}}】移除了【{{datasetName}}】中的【{{collectionName}}】的标签【{{tagName}}】", + "log_collection_permission_inheritance_resumed": "【{{name}}】将【{{datasetName}}】中【{{collectionName}}】的权限恢复为继承【{{parentResource}}】", "log_search_test": "【{{name}}】在名为【{{datasetName}}】的【{{datasetType}}】执行搜索测试操作", "log_set_invoice_header": "【{{name}}】进行了设置发票抬头操作", "log_time": "操作时间", @@ -240,6 +258,7 @@ "log_update_app_info": "【{{name}}】更新了名为【{{appName}}】的【{{appType}}】:【{{newItemNames}}】为【{{newItemValues}}】", "log_update_app_publish_channel": "【{{name}}】给名为【{{appName}}】的【{{appType}}】更新了名为【{{channelName}}】的渠道", "log_update_collection": "【{{name}}】在名为【{{datasetName}}】的【{{datasetType}}】更新了名为【{{collectionName}}】的集合", + "log_dataset_collection_updated": "【{{name}}】将【{{datasetName}}】中【{{collectionName}}】的【{{changeItem}}】从【{{oldValue}}】调整为【{{newValue}}】", "log_update_data": "【{{name}}】在名为【{{datasetName}}】的【{{datasetType}}】在名为【{{collectionName}}】的集合更新数据", "log_update_dataset": "【{{name}}】更新了名为【{{datasetName}}】的【{{datasetType}}】", "log_update_dataset_collaborator": "【{{name}}】将名为【{{datasetName}}】的【{{datasetType}}】的合作者更新为:组织:【{{orgList}}】,群组:【{{groupList}}】,成员【{{tmbList}}】;权限更新为:【{{readPermission}}】,【{{writePermission}}】,【{{managePermission}}】", diff --git a/packages/web/i18n/zh-Hant/account_team.json b/packages/web/i18n/zh-Hant/account_team.json index 08bf6b2be4d0..a5ca215c9c67 100644 --- a/packages/web/i18n/zh-Hant/account_team.json +++ b/packages/web/i18n/zh-Hant/account_team.json @@ -60,6 +60,18 @@ "dataset_training_task_retry": "重試單個訓練任務", "dataset_training_record_delete": "刪除集合訓練記錄", "dataset_index_rebuild": "重建知識庫索引", + "dataset_collection_created": "創建集合", + "dataset_collection_updated": "更新集合屬性", + "dataset_collection_exported": "導出集合內容", + "dataset_collection_synced": "同步集合", + "collection_tag_added": "為集合新增標籤", + "collection_tag_removed": "移除集合標籤", + "collection_permission_inheritance_resumed": "恢復集合繼承權限", + "dataset_content_change_item_name": "名稱", + "dataset_content_change_item_location": "位置", + "dataset_content_change_item_status": "啟用狀態", + "dataset_content_status_enabled": "啟用", + "dataset_content_status_disabled": "停用", "create_data": "插入數據", "create_dataset": "創建知識庫", "create_dataset_folder": "創建知識庫文件夾", @@ -195,6 +207,7 @@ "log_dataset_training_task_retry": "【{{name}}】重試了知識庫【{{datasetName}}】中集合【{{collectionName}}】的單個訓練任務", "log_dataset_training_record_delete": "【{{name}}】刪除了知識庫【{{datasetName}}】中集合【{{collectionName}}】的訓練記錄", "log_dataset_index_rebuild": "【{{name}}】將知識庫【{{datasetName}}】的索引模型從【{{oldModelName}}】調整為【{{newModelName}}】,並重建索引", + "log_dataset_collection_created": "【{{name}}】在【{{location}}】中建立了【{{objectType}}】【{{collectionName}}】", "log_create_data": "【{{name}}】在名為【{{datasetName}}】的【{{datasetType}}】往名為【{{collectionName}}】的集合插入數據", "log_create_dataset": "【{{name}}】創建了名為【{{datasetName}}】的【{{datasetType}}】", "log_create_dataset_folder": "【{{name}}】創建了名為{{folderName}}】的文件夾", @@ -218,6 +231,7 @@ "log_export_app_chat_log": "【{{name}}】導出了名為【{{appName}}】的【{{appType}}】的聊天記錄", "log_export_bill_records": "【{{name}}】導出了賬單記錄", "log_export_dataset": "【{{name}}】導出了名為【{{datasetName}}】的【{{datasetType}}】", + "log_dataset_collection_exported": "【{{name}}】導出了【{{datasetName}}】中【{{collectionName}}】的集合內容", "log_export_evaluation": "【{{name}}】導出了名為【{{appName}}】的【{{appType}}】評測數據", "log_join_team": "【{{name}}】通過邀請鏈接【{{link}}】加入團隊", "log_kick_out_team": "{{name}} 移除了成員 {{memberName}}", @@ -228,6 +242,10 @@ "log_recover_team_member": "【{{name}}】恢復了成員【{{memberName}}】", "log_relocate_department": "【{{name}}】移動了部門【{{departmentName}}】", "log_retrain_collection": "【{{name}}】在名為【{{datasetName}}】的【{{datasetType}}】重新訓練了名為【{{collectionName}}】的集合", + "log_dataset_collection_synced": "【{{name}}】同步了【{{datasetName}}】中的【{{collectionName}}】", + "log_collection_tag_added": "【{{name}}】為【{{datasetName}}】中的【{{collectionName}}】新增了標籤【{{tagName}}】", + "log_collection_tag_removed": "【{{name}}】移除了【{{datasetName}}】中的【{{collectionName}}】的標籤【{{tagName}}】", + "log_collection_permission_inheritance_resumed": "【{{name}}】將【{{datasetName}}】中【{{collectionName}}】的權限恢復為繼承【{{parentResource}}】", "log_search_test": "【{{name}}】在名為【{{datasetName}}】的【{{datasetType}}】執行搜索測試操作", "log_set_invoice_header": "【{{name}}】進行了設置發票抬頭操作", "log_time": "操作時間", @@ -240,6 +258,7 @@ "log_update_app_info": "【{{name}}】更新了名為【{{appName}}】的【{{appType}}】:【{{newItemNames}}】為【{{newItemValues}}】", "log_update_app_publish_channel": "【{{name}}】給名為【{{appName}}】的【{{appType}}】更新了名為【{{channelName}}】的渠道", "log_update_collection": "【{{name}}】在名為【{{datasetName}}】的【{{datasetType}}】更新了名為【{{collectionName}}】的集合", + "log_dataset_collection_updated": "【{{name}}】將【{{datasetName}}】中【{{collectionName}}】的【{{changeItem}}】從【{{oldValue}}】調整為【{{newValue}}】", "log_update_data": "【{{name}}】在名為【{{datasetName}}】的【{{datasetType}}】在名為【{{collectionName}}】的集合更新數據", "log_update_dataset": "【{{name}}】更新了名為【{{datasetName}}】的【{{datasetType}}】", "log_update_dataset_collaborator": "【{{name}}】將名為【{{datasetName}}】的【{{datasetType}}】的合作者更新為:組織:【{{orgList}}】,群組:【{{groupList}}】,成員【{{tmbList}}】;權限更新為:【{{readPermission}}】,【{{writePermission}}】,【{{managePermission}}】", diff --git a/packages/web/support/user/audit/constants.ts b/packages/web/support/user/audit/constants.ts index 30c5609df62b..f661c7f565d4 100644 --- a/packages/web/support/user/audit/constants.ts +++ b/packages/web/support/user/audit/constants.ts @@ -1,3 +1,4 @@ +import type { DatasetContentAuditAction } from '@fastgpt/global/core/dataset/constants'; import { AuditEventEnum, AdminAuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; import { i18nT } from '@fastgpt/global/common/i18n/utils'; @@ -407,6 +408,18 @@ export const auditLogMap = { params: {} as { name?: string; folderName: string } }, //Collection + ['dataset.collection.create']: { + content: i18nT('account_team:log_dataset_collection_created'), + typeLabel: i18nT('account_team:dataset_collection_created'), + params: {} as { + name?: string; + collectionName: string; + objectType: string; + /** 知识库名/父目录链,已包含知识库自身 */ + location: string; + } + }, + /** @deprecated 历史日志展示用,新写入统一走 'dataset.collection.create'。 */ [AuditEventEnum.CREATE_COLLECTION]: { content: i18nT('account_team:log_create_collection'), typeLabel: i18nT('account_team:create_collection'), @@ -417,6 +430,7 @@ export const auditLogMap = { datasetType: string; } }, + /** @deprecated 历史日志展示用,新写入统一走 'dataset.collection.update'。 */ [AuditEventEnum.UPDATE_COLLECTION]: { content: i18nT('account_team:log_update_collection'), typeLabel: i18nT('account_team:update_collection'), @@ -427,6 +441,67 @@ export const auditLogMap = { datasetType: string; } }, + ['dataset.collection.update']: { + content: i18nT('account_team:log_dataset_collection_updated'), + typeLabel: i18nT('account_team:dataset_collection_updated'), + params: {} as { + name?: string; + datasetName: string; + collectionName: string; + objectType: string; + action: DatasetContentAuditAction; + changeItem: string; + oldValue: string; + newValue: string; + } + }, + ['dataset.collection.export']: { + content: i18nT('account_team:log_dataset_collection_exported'), + typeLabel: i18nT('account_team:dataset_collection_exported'), + params: {} as { + name?: string; + datasetName: string; + collectionName: string; + objectType: string; + exportScope: string; + } + }, + ['dataset.collection.sync']: { + content: i18nT('account_team:log_dataset_collection_synced'), + typeLabel: i18nT('account_team:dataset_collection_synced'), + params: {} as { name?: string; datasetName: string; collectionName: string } + }, + ['dataset.collection.tag.add']: { + content: i18nT('account_team:log_collection_tag_added'), + typeLabel: i18nT('account_team:collection_tag_added'), + params: {} as { + name?: string; + datasetName: string; + collectionName: string; + tagName: string; + } + }, + ['dataset.collection.tag.remove']: { + content: i18nT('account_team:log_collection_tag_removed'), + typeLabel: i18nT('account_team:collection_tag_removed'), + params: {} as { + name?: string; + datasetName: string; + collectionName: string; + tagName: string; + } + }, + ['dataset.collection.permission.inheritance_resume']: { + content: i18nT('account_team:log_collection_permission_inheritance_resumed'), + typeLabel: i18nT('account_team:collection_permission_inheritance_resumed'), + params: {} as { + name?: string; + datasetName: string; + collectionName: string; + /** 恢复继承后跟随的上级:父目录名,根集合时为知识库名 */ + parentResource: string; + } + }, [AuditEventEnum.DELETE_COLLECTION]: { content: i18nT('account_team:log_delete_collection'), typeLabel: i18nT('account_team:delete_collection'), diff --git a/pro b/pro index 3db0b0e209a9..433adf2a5169 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 3db0b0e209a98a71f7d427b27c83d6cd25e3fc76 +Subproject commit 433adf2a5169e1cfdf9c6fc83bb56c01749239f5 diff --git a/projects/app/src/pages/api/core/dataset/collection/create.ts b/projects/app/src/pages/api/core/dataset/collection/create.ts index 8e056c72ea0a..f31dfb91896a 100644 --- a/projects/app/src/pages/api/core/dataset/collection/create.ts +++ b/projects/app/src/pages/api/core/dataset/collection/create.ts @@ -2,11 +2,16 @@ import { authDatasetCollectionCreate } from '@fastgpt/service/support/permission import { createOneCollection } from '@fastgpt/service/core/dataset/collection/controller'; import { NextAPI } from '@/service/middleware/entry'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; -import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; -import { getI18nDatasetType } from '@fastgpt/service/support/user/audit/util'; +import { + getCollectionAuditLocation, + getCollectionAuditObjectType +} from '@fastgpt/service/core/dataset/collection/audit'; import type { ApiRequestProps } from '@fastgpt/next/type'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; -import { DatasetCollectionTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import { + DatasetAuditEventEnum, + DatasetCollectionTypeEnum +} from '@fastgpt/global/core/dataset/constants'; import { CreateCollectionBodySchema, CreateCollectionResponseSchema, @@ -33,18 +38,19 @@ async function handler(req: ApiRequestProps): Promise { - addAuditLog({ - tmbId, - teamId, - event: AuditEventEnum.CREATE_COLLECTION, - params: { - collectionName: body.name, + void addAuditLog({ + tmbId, + teamId, + event: DatasetAuditEventEnum.DATASET_COLLECTION_CREATE, + params: { + collectionName: body.name, + objectType: getCollectionAuditObjectType(body.type), + location: await getCollectionAuditLocation({ datasetName: dataset.name, - datasetType: getI18nDatasetType(dataset.type) - } - }); - })(); + parentId: body.parentId + }) + } + }); return CreateCollectionResponseSchema.parse(_id); } diff --git a/projects/app/src/pages/api/core/dataset/collection/create/backup.ts b/projects/app/src/pages/api/core/dataset/collection/create/backup.ts index 74cafc2f8e32..89171d2b56f7 100644 --- a/projects/app/src/pages/api/core/dataset/collection/create/backup.ts +++ b/projects/app/src/pages/api/core/dataset/collection/create/backup.ts @@ -4,6 +4,7 @@ import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; import { authDatasetCollectionCreate } from '@fastgpt/service/support/permission/dataset/auth'; import { createCollectionAndInsertData } from '@fastgpt/service/core/dataset/collection/controller'; import { + DatasetAuditEventEnum, DatasetCollectionDataProcessModeEnum, DatasetCollectionTypeEnum } from '@fastgpt/global/core/dataset/constants'; @@ -17,6 +18,11 @@ import { import { checkDatasetIndexLimit } from '@fastgpt/service/support/permission/teamLimit'; import { parseDatasetImportFile } from '@fastgpt/service/core/dataset/importFile'; import { decodeMultipartFilename } from '@fastgpt/service/common/s3/filename'; +import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; +import { + getCollectionAuditLocation, + getCollectionAuditObjectType +} from '@fastgpt/service/core/dataset/collection/audit'; const logger = getLogger(LogCategories.MODULE.DATASET.COLLECTION); async function handler(req: ApiRequestProps): Promise { @@ -90,6 +96,20 @@ async function handler(req: ApiRequestProps): Promise { const { fileId, customPdfParse, ...body } = parseApiInput({ @@ -43,7 +51,7 @@ async function handler(req: ApiRequestProps): Promise { const filepaths: string[] = []; @@ -105,7 +111,7 @@ async function handler(req: ApiRequestProps): Promise { const { link, ...body } = parseApiInput({ req, bodySchema: CreateLinkCollectionBodySchema }).body; @@ -27,7 +35,7 @@ async function handler(req: ApiRequestProps): Promise { @@ -90,6 +96,20 @@ async function handler(req: ApiRequestProps): Promise { const { name, text, ...body } = parseApiInput({ @@ -53,6 +61,21 @@ async function handler(req: ApiRequestProps): Promise { + if (auditFinished) return; + + auditFinished = true; + void addAuditLog({ + teamId: String(userTeamId), + tmbId, + event: DatasetAuditEventEnum.DATASET_COLLECTION_EXPORT, + params: { + datasetName: collection.dataset.name, + collectionName: collection.name, + objectType: getCollectionAuditObjectType(collection.type), + exportScope: 'chatItemDataId' in parseBody ? 'chat_quote' : 'collection' + } + }); + }; write(`\uFEFFq,a`); @@ -138,6 +159,7 @@ async function handler(req: ApiRequestProps, res: NextApiResponse) { write(`\n${sanitizedQ},${sanitizedA}`); } catch (error) { logger.error(`export usage error`, { error }); + recordExportAudit(); cursor.destroy(); return; } @@ -147,11 +169,13 @@ async function handler(req: ApiRequestProps, res: NextApiResponse) { cursor.on('end', () => { cursor.close(); + recordExportAudit(); res.end(); }); cursor.on('error', (err) => { logger.error(`export usage error`, { error: err }); + recordExportAudit(); res.status(500); res.end(); }); diff --git a/projects/app/src/pages/api/core/dataset/collection/paths.ts b/projects/app/src/pages/api/core/dataset/collection/paths.ts index c783cd5950cd..b74d63d2ab68 100644 --- a/projects/app/src/pages/api/core/dataset/collection/paths.ts +++ b/projects/app/src/pages/api/core/dataset/collection/paths.ts @@ -1,10 +1,5 @@ import { authDatasetCollection } from '@fastgpt/service/support/permission/dataset/auth'; import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; -import type { - ParentIdType, - ParentTreePathItemType -} from '@fastgpt/global/common/parentFolder/type'; -import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema'; import { NextAPI } from '@/service/middleware/entry'; import type { ApiRequestProps } from '@fastgpt/next/type'; import { @@ -13,6 +8,7 @@ import { type GetCollectionPathsResponseType } from '@fastgpt/global/openapi/core/dataset/collection/api'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { getDatasetCollectionPaths } from '@fastgpt/service/core/dataset/collection/paths'; export async function handler(req: ApiRequestProps): Promise { const { sourceId } = parseApiInput({ req, querySchema: GetCollectionPathsQuerySchema }).query; @@ -34,25 +30,3 @@ export async function handler(req: ApiRequestProps): Promise { - async function find(parentId: ParentIdType): Promise { - if (!parentId) { - return []; - } - const parent = await MongoDatasetCollection.findOne({ _id: parentId }, 'name parentId'); - - if (!parent) return []; - - const paths = await find(parent.parentId ?? undefined); - paths.push({ parentId, parentName: parent.name }); - - return paths; - } - - return find(parentId); -} diff --git a/projects/app/src/pages/api/core/dataset/collection/resumeInheritPermission.ts b/projects/app/src/pages/api/core/dataset/collection/resumeInheritPermission.ts index 95c8f08dd176..d983738705c2 100644 --- a/projects/app/src/pages/api/core/dataset/collection/resumeInheritPermission.ts +++ b/projects/app/src/pages/api/core/dataset/collection/resumeInheritPermission.ts @@ -1,3 +1,4 @@ +import { DatasetAuditEventEnum } from '@fastgpt/global/core/dataset/constants'; import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { authDatasetCollection } from '@fastgpt/service/support/permission/dataset/auth'; @@ -8,6 +9,8 @@ import { type ResumeCollectionInheritPermissionBody } from '@fastgpt/global/openapi/core/dataset/collection/api'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; +import { getDatasetCollectionPaths } from '@fastgpt/service/core/dataset/collection/paths'; async function handler(req: ApiRequestProps) { const { collectionId } = parseApiInput({ @@ -15,13 +18,22 @@ async function handler(req: ApiRequestProps paths.at(-1)?.parentName) + .catch(() => undefined) + : undefined; + await resumeCollectionInheritPermission({ collection: { _id: String(collection._id), @@ -32,6 +44,17 @@ async function handler(req: ApiRequestProps { const { collectionId } = parseApiInput({ req, bodySchema: SyncCollectionBodySchema }).body; - const { collection } = await authDatasetCollection({ + const { collection, teamId, tmbId } = await authDatasetCollection({ req, authToken: true, authApiKey: true, @@ -29,7 +31,21 @@ async function handler(req: ApiRequestProps): Promise (value ? String(value) : null); + const oldParentId = normalizeParentId(collection.parentId); + const newParentId = normalizeParentId(parentId); + // 只有真正发生变化的字段才落审计,避免前端回传原值也产生日志 + const actualMove = isMove && oldParentId !== newParentId; + const actualRename = Boolean(name) && name !== collection.name; + const actualForbidChange = forbid !== undefined && Boolean(forbid) !== Boolean(collection.forbid); + const auditChanges: CollectionAuditChange[] = []; + + /** 拼接审计展示用的完整路径(知识库/父目录/集合名),取路径失败时退回集合名。 */ + const getCollectionPath = async ({ + parentId: pathParentId, + name: pathName + }: { + parentId: string | null; + name: string; + }) => { + const location = await getCollectionAuditLocation({ + datasetName: collection.dataset?.name ?? '', + parentId: pathParentId + }); + return [location, pathName].filter(Boolean).join('/'); + }; + + if (actualRename) { + auditChanges.push({ + action: DatasetContentAuditActionEnum.RENAME, + changeItem: i18nT('account_team:dataset_content_change_item_name'), + oldValue: collection.name, + newValue: name! + }); + } + + if (actualForbidChange) { + auditChanges.push({ + action: forbid ? DatasetContentAuditActionEnum.DISABLE : DatasetContentAuditActionEnum.ENABLE, + changeItem: i18nT('account_team:dataset_content_change_item_status'), + oldValue: collection.forbid + ? i18nT('account_team:dataset_content_status_disabled') + : i18nT('account_team:dataset_content_status_enabled'), + newValue: forbid + ? i18nT('account_team:dataset_content_status_disabled') + : i18nT('account_team:dataset_content_status_enabled') + }); + } + + // 移动需要在事务前先取旧路径,事务提交后 collection 已指向新父级 + const oldCollectionPath = actualMove + ? await getCollectionPath({ parentId: oldParentId, name: collection.name }) + : undefined; + // Move collection:parentId 显式提供时在事务前校验深度/环 if (isMove) { await checkMoveFolderDepth({ @@ -177,18 +244,35 @@ async function handler(req: ApiRequestProps) { } }); - (async () => { - addAuditLog({ + if (actualMove) { + const newCollectionPath = await getCollectionPath({ + parentId: newParentId, + name: actualRename ? name! : collection.name + }); + auditChanges.push({ + action: DatasetContentAuditActionEnum.MOVE, + changeItem: i18nT('account_team:dataset_content_change_item_location'), + oldValue: oldCollectionPath ?? collection.name, + newValue: newCollectionPath + }); + } + + if (auditChanges.length === 0) return; + + // 每个变更对象一条日志,保留审计列表的逐项追溯粒度 + void addAuditLogs( + auditChanges.map((change) => ({ tmbId, teamId, - event: AuditEventEnum.UPDATE_COLLECTION, + event: DatasetAuditEventEnum.DATASET_COLLECTION_UPDATE, params: { + datasetName: collection.dataset?.name ?? '', collectionName: collection.name, - datasetName: collection.dataset?.name || '', - datasetType: getI18nDatasetType(collection.dataset?.type || '') + objectType: getCollectionAuditObjectType(collection.type), + ...change } - }); - })(); + })) + ); } export default NextAPI(handler); diff --git a/projects/app/src/pages/api/core/dataset/createWithFiles.ts b/projects/app/src/pages/api/core/dataset/createWithFiles.ts index 2b39d58b6d2e..e1e5b9cf02fb 100644 --- a/projects/app/src/pages/api/core/dataset/createWithFiles.ts +++ b/projects/app/src/pages/api/core/dataset/createWithFiles.ts @@ -1,6 +1,7 @@ import { NextAPI } from '@/service/middleware/entry'; import { parseParentIdInMongo } from '@fastgpt/global/common/parentFolder/utils'; import { + DatasetAuditEventEnum, ChunkSettingModeEnum, ChunkTriggerConfigTypeEnum, DataChunkSplitModeEnum, @@ -37,7 +38,12 @@ import { authDataset } from '@fastgpt/service/support/permission/dataset/auth'; import { getAuthorizedModelIds } from '@fastgpt/service/support/permission/model/auth'; import { checkTeamDatasetLimit } from '@fastgpt/service/support/permission/teamLimit'; import { authUserPer } from '@fastgpt/service/support/permission/user/auth'; -import { addAuditLog, getI18nDatasetType } from '@fastgpt/service/support/user/audit/util'; +import { + addAuditLog, + addAuditLogs, + getI18nDatasetType +} from '@fastgpt/service/support/user/audit/util'; +import { getCollectionAuditObjectType } from '@fastgpt/service/core/dataset/collection/audit'; async function handler(req: ApiRequestProps): Promise { const { datasetParams, files } = parseApiInput({ @@ -205,6 +211,23 @@ async function handler(req: ApiRequestProps): Promise 0) { + void addAuditLogs( + files.map((file) => ({ + tmbId, + teamId, + event: DatasetAuditEventEnum.DATASET_COLLECTION_CREATE, + params: { + collectionName: file.name, + objectType: getCollectionAuditObjectType(DatasetCollectionTypeEnum.file), + // 新建知识库的集合都落在根目录,没有父级路径需要展开 + location: name + } + })) + ); + } + return CreateDatasetWithFilesResponseSchema.parse(result); } catch (error) { return Promise.reject(error); diff --git a/projects/app/test/api/core/dataset/collection/create/fileSourceLifecycle.test.ts b/projects/app/test/api/core/dataset/collection/create/fileSourceLifecycle.test.ts index 10052e8edbbd..56d3b35562be 100644 --- a/projects/app/test/api/core/dataset/collection/create/fileSourceLifecycle.test.ts +++ b/projects/app/test/api/core/dataset/collection/create/fileSourceLifecycle.test.ts @@ -62,7 +62,11 @@ vi.mock('@fastgpt/global/common/i18n/utils', () => ({ })); vi.mock('@fastgpt/service/common/logger', () => ({ - LogCategories: { MODULE: { DATASET: { COLLECTION: 'dataset-collection' } } }, + LogCategories: { + MODULE: { DATASET: { COLLECTION: 'dataset-collection' } }, + // 审计模块顶层会取 INFRA.MONGO 建 logger;漏掉会在模块初始化阶段直接抛错 + INFRA: { MONGO: 'mongo' } + }, getLogger: () => ({ warn: vi.fn(), error: vi.fn() diff --git a/projects/app/test/api/core/dataset/collection/paths.test.ts b/projects/app/test/api/core/dataset/collection/paths.test.ts index ae5d35cd3936..8a950346e408 100644 --- a/projects/app/test/api/core/dataset/collection/paths.test.ts +++ b/projects/app/test/api/core/dataset/collection/paths.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getDatasetCollectionPaths } from '@/pages/api/core/dataset/collection/paths'; +import { getDatasetCollectionPaths } from '@fastgpt/service/core/dataset/collection/paths'; import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema'; vi.mock('@fastgpt/service/core/dataset/collection/schema', () => ({ @@ -64,21 +64,25 @@ describe('getDatasetCollectionPaths', () => { ]); }); - it('should handle circular references gracefully', async () => { - vi.mocked(MongoDatasetCollection.findOne) - .mockResolvedValueOnce({ - _id: 'col1', - name: 'Collection 1', - parentId: 'col2' - }) - .mockResolvedValueOnce({ - _id: 'col2', - name: 'Collection 2', - parentId: 'col1' - }); + it('should stop at the first repeated ancestor when data forms a cycle', async () => { + // 互指的脏数据:持续返回同一组文档,没有环检测时会无限查询下去。 + // 这里在超出预期次数后直接抛错,让回归以失败而不是挂住的形式暴露。 + const docs: Record = { + col1: { _id: 'col1', name: 'Collection 1', parentId: 'col2' }, + col2: { _id: 'col2', name: 'Collection 2', parentId: 'col1' } + }; + let callCount = 0; + vi.mocked(MongoDatasetCollection.findOne).mockImplementation((async (filter: { + _id: string; + }) => { + callCount += 1; + if (callCount > 5) throw new Error('cycle not detected'); + return docs[String(filter._id)] ?? null; + }) as never); const result = await getDatasetCollectionPaths({ parentId: 'col1' }); + expect(callCount).toBe(2); expect(result).toEqual([ { parentId: 'col2', parentName: 'Collection 2' }, { parentId: 'col1', parentName: 'Collection 1' } diff --git a/projects/app/test/api/core/dataset/collection/resumeInheritPermission.test.ts b/projects/app/test/api/core/dataset/collection/resumeInheritPermission.test.ts new file mode 100644 index 000000000000..ee8762eac31e --- /dev/null +++ b/projects/app/test/api/core/dataset/collection/resumeInheritPermission.test.ts @@ -0,0 +1,150 @@ +import handler from '@/pages/api/core/dataset/collection/resumeInheritPermission'; +import { DatasetCollectionTypeEnum, DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; +import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema'; +import { MongoTeamAudit } from '@fastgpt/service/support/user/audit/schema'; +import { getFakeUsers } from '@test/datas/users'; +import { Call } from '@test/utils/request'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock('@fastgpt/service/support/user/audit/util'); + +describe('resume collection inherit permission api', () => { + beforeEach(async () => { + await MongoTeamAudit.deleteMany({}); + }); + + it('恢复根集合继承权限时记录知识库作为继承来源', async () => { + const users = await getFakeUsers(1); + const dataset = await MongoDataset.create({ + name: 'audit-dataset', + type: DatasetTypeEnum.dataset, + teamId: users.owner.teamId, + tmbId: users.owner.tmbId, + vectorModelId: 'test-model', + collectionPermissionEnabled: true + }); + const collection = await MongoDatasetCollection.create({ + name: 'audit-collection', + type: DatasetCollectionTypeEnum.file, + teamId: users.owner.teamId, + tmbId: users.owner.tmbId, + datasetId: dataset._id, + inheritPermission: false + }); + + const res = await Call(handler, { + auth: users.owner, + body: { collectionId: String(collection._id) } + }); + + expect(res.code).toBe(200); + await expect(MongoDatasetCollection.findById(collection._id).lean()).resolves.toMatchObject({ + inheritPermission: true + }); + + await vi.waitFor( + async () => { + const audit = await MongoTeamAudit.findOne({ + teamId: users.owner.teamId, + event: 'dataset.collection.permission.inheritance_resume' + }).lean(); + expect(audit).not.toBeNull(); + // 根集合没有父目录,直接继承知识库 + expect(audit?.metadata).toEqual({ + datasetName: 'audit-dataset', + collectionName: 'audit-collection', + parentResource: 'audit-dataset' + }); + }, + { timeout: 5000 } + ); + }); + + it('嵌套集合记录直接父目录作为继承来源', async () => { + const users = await getFakeUsers(1); + const dataset = await MongoDataset.create({ + name: 'nested-audit-dataset', + type: DatasetTypeEnum.dataset, + teamId: users.owner.teamId, + tmbId: users.owner.tmbId, + vectorModelId: 'test-model', + collectionPermissionEnabled: true + }); + const parent = await MongoDatasetCollection.create({ + name: 'audit-folder', + type: DatasetCollectionTypeEnum.folder, + teamId: users.owner.teamId, + tmbId: users.owner.tmbId, + datasetId: dataset._id, + inheritPermission: true + }); + const collection = await MongoDatasetCollection.create({ + name: 'nested-audit-collection', + type: DatasetCollectionTypeEnum.file, + teamId: users.owner.teamId, + tmbId: users.owner.tmbId, + datasetId: dataset._id, + parentId: parent._id, + inheritPermission: false + }); + + const res = await Call(handler, { + auth: users.owner, + body: { collectionId: String(collection._id) } + }); + + expect(res.code).toBe(200); + + await vi.waitFor( + async () => { + const audit = await MongoTeamAudit.findOne({ + teamId: users.owner.teamId, + event: 'dataset.collection.permission.inheritance_resume' + }).lean(); + expect(audit?.metadata).toEqual({ + datasetName: 'nested-audit-dataset', + collectionName: 'nested-audit-collection', + parentResource: 'audit-folder' + }); + }, + { timeout: 5000 } + ); + }); + + it('无管理权限时既不恢复继承也不写审计', async () => { + const users = await getFakeUsers(1); + const dataset = await MongoDataset.create({ + name: 'denied-audit-dataset', + type: DatasetTypeEnum.dataset, + teamId: users.owner.teamId, + tmbId: users.owner.tmbId, + vectorModelId: 'test-model', + collectionPermissionEnabled: true + }); + const collection = await MongoDatasetCollection.create({ + name: 'denied-audit-collection', + type: DatasetCollectionTypeEnum.file, + teamId: users.owner.teamId, + tmbId: users.owner.tmbId, + datasetId: dataset._id, + inheritPermission: false + }); + + const res = await Call(handler, { + auth: users.members[0], + body: { collectionId: String(collection._id) } + }); + + expect(res.code).not.toBe(200); + await expect(MongoDatasetCollection.findById(collection._id).lean()).resolves.toMatchObject({ + inheritPermission: false + }); + await expect( + MongoTeamAudit.countDocuments({ + teamId: users.owner.teamId, + event: 'dataset.collection.permission.inheritance_resume' + }) + ).resolves.toBe(0); + }); +}); diff --git a/projects/app/test/api/core/dataset/collection/update.test.ts b/projects/app/test/api/core/dataset/collection/update.test.ts index e99363a27cd8..3568eaf4c329 100644 --- a/projects/app/test/api/core/dataset/collection/update.test.ts +++ b/projects/app/test/api/core/dataset/collection/update.test.ts @@ -10,6 +10,7 @@ import { import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema'; import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema'; +import { MongoTeamAudit } from '@fastgpt/service/support/user/audit/schema'; import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { parseHeaderCert } from '@fastgpt/service/support/permission/auth/common'; import { getFakeUsers } from '@test/datas/users'; @@ -161,4 +162,148 @@ describe('update collection', () => { expect(allowed.code).toBe(200); expect((await MongoDatasetCollection.findById(file._id).lean())?.parentId).toBeNull(); }); + + it('为重命名、移动和启用状态分别写入一条内容对象审计', async () => { + const { actor, file, target, grant } = await fixture(); + await grant(String(file._id), WriteRoleVal); + + expect( + ( + await Call(updateHandler, { + auth: actor, + body: { id: String(file._id), name: 'renamed' } + }) + ).code + ).toBe(200); + expect( + ( + await Call(updateHandler, { + auth: actor, + body: { id: String(file._id), parentId: String(target._id) } + }) + ).code + ).toBe(200); + expect( + ( + await Call(updateHandler, { + auth: actor, + body: { id: String(file._id), forbid: true } + }) + ).code + ).toBe(200); + + await vi.waitFor( + async () => { + const logs = await MongoTeamAudit.find({ + teamId: actor.teamId, + event: 'dataset.collection.update' + }) + .sort({ timestamp: 1 }) + .lean(); + + expect(logs).toHaveLength(3); + // 每条日志记录执行当时的名字:改名后后续两条已是新名 + expect(logs.map((log) => log.metadata)).toEqual([ + expect.objectContaining({ + datasetName: 'update-test', + collectionName: 'file', + action: 'rename', + oldValue: 'file', + newValue: 'renamed' + }), + expect.objectContaining({ + collectionName: 'renamed', + action: 'move', + oldValue: 'update-test/source/renamed', + newValue: 'update-test/target/renamed' + }), + expect.objectContaining({ + collectionName: 'renamed', + action: 'disable', + oldValue: 'account_team:dataset_content_status_enabled', + newValue: 'account_team:dataset_content_status_disabled' + }) + ]); + }, + { timeout: 5000 } + ); + }); + + it('重命名与移动合并时仍各自一条日志,位置使用新名称', async () => { + const { actor, file, target, grant } = await fixture(); + await grant(String(file._id), WriteRoleVal); + + const result = await Call(updateHandler, { + auth: actor, + body: { id: String(file._id), name: 'renamed', parentId: String(target._id) } + }); + expect(result.code).toBe(200); + + await vi.waitFor( + async () => { + const logs = await MongoTeamAudit.find({ + teamId: actor.teamId, + event: 'dataset.collection.update' + }) + .sort({ timestamp: 1 }) + .lean(); + + expect(logs.map((log) => log.metadata.action)).toEqual(['rename', 'move']); + expect(logs[1].metadata).toEqual( + expect.objectContaining({ + oldValue: 'update-test/source/file', + newValue: 'update-test/target/renamed' + }) + ); + }, + { timeout: 5000 } + ); + }); + + it('无实际变更时不写入审计', async () => { + const { actor, file, grant } = await fixture(); + await grant(String(file._id), WriteRoleVal); + + const result = await Call(updateHandler, { + auth: actor, + body: { id: String(file._id), name: 'file', forbid: false } + }); + expect(result.code).toBe(200); + + const logs = await MongoTeamAudit.find({ + teamId: actor.teamId, + event: 'dataset.collection.update' + }).lean(); + expect(logs).toHaveLength(0); + }); + + it('文件夹禁用只记录文件夹自身,子 collection 只执行状态级联', async () => { + const { actor, source, file } = await fixture(); + const result = await Call(updateHandler, { + auth: actor, + body: { id: String(source._id), forbid: true } + }); + + expect(result.code).toBe(200); + expect((await MongoDatasetCollection.findById(file._id).lean())?.forbid).toBe(true); + + await vi.waitFor( + async () => { + const logs = await MongoTeamAudit.find({ + teamId: actor.teamId, + event: 'dataset.collection.update' + }).lean(); + expect(logs).toHaveLength(1); + expect(logs[0].metadata).toEqual( + expect.objectContaining({ + collectionName: 'source', + action: 'disable', + oldValue: 'account_team:dataset_content_status_enabled', + newValue: 'account_team:dataset_content_status_disabled' + }) + ); + }, + { timeout: 5000 } + ); + }); });