diff --git a/docs/pb_document_tables.md b/docs/pb_document_tables.md new file mode 100644 index 0000000..a82d52d --- /dev/null +++ b/docs/pb_document_tables.md @@ -0,0 +1,101 @@ +# PocketBase 文档相关表结构 + +本方案新增 3 张 PocketBase `base collection`,统一采用业务 id 字段进行关联,不直接依赖 PocketBase 自动生成的 `id` 作为业务主键。 + +补充约定: + +- `document_image`、`document_video` 只保存关联的 `attachments_id`,不直接存文件。 +- `document_keywords`、`document_product_categories`、`document_application_scenarios`、`document_hotel_type` 统一使用竖线分隔字符串,例如:`分类A|分类B|分类C`。 +- `document_owner` 的业务含义为“上传者openid”。 +- `attachments_link` 是 PocketBase `file` 字段,保存附件本体;访问链接由 PocketBase 根据记录和文件名生成。 + +--- + +## 1. `tbl_attachments` 附件表 + +**类型:** Base Collection + +| 字段名 | 类型 | 备注 | +| :--- | :--- | :--- | +| attachments_id | text | 附件业务 id,唯一标识符 | +| attachments_link | file | 附件本体,单文件,不限制文件类型,单文件上限 100MB | +| attachments_filename | text | 原始文件名 | +| attachments_filetype | text | 文件类型/MIME | +| attachments_size | number | 附件大小 | +| attachments_owner | text | 上传者业务 id | +| attachments_md5 | text | 附件 MD5 码 | +| attachments_ocr | text | OCR 识别结果 | +| attachments_status | text | 附件状态 | +| attachments_remark | text | 备注 | + +**索引规划:** + +- `attachments_id` 唯一索引 +- `attachments_owner` 普通索引 +- `attachments_status` 普通索引 + +--- + +## 2. `tbl_document` 文档表 + +**类型:** Base Collection + +| 字段名 | 类型 | 备注 | +| :--- | :--- | :--- | +| document_id | text | 文档业务 id,唯一标识符 | +| document_effect_date | date | 文档生效日期 | +| document_expiry_date | date | 文档到期日期 | +| document_type | text | 文档类型 | +| document_title | text | 文档标题 | +| document_subtitle | text | 文档副标题 | +| document_summary | text | 文档摘要 | +| document_content | text | 正文内容,保存 Markdown 原文 | +| document_image | text | 关联 `attachments_id` | +| document_video | text | 关联 `attachments_id` | +| document_owner | text | 上传者openid | +| document_relation_model | text | 关联机型/模型标识 | +| document_keywords | text | 关键词,竖线分隔 | +| document_share_count | number | 分享次数 | +| document_download_count | number | 下载次数 | +| document_favorite_count | number | 收藏次数 | +| document_status | text | 文档状态 | +| document_embedding_status | text | 文档嵌入状态 | +| document_embedding_error | text | 文档错误原因 | +| document_embedding_lasttime | date | 最后更新日期 | +| document_vector_version | text | 向量版本号或模型名称 | +| document_product_categories | text | 适用产品类别,竖线分隔 | +| document_application_scenarios | text | 适用场景,竖线分隔 | +| document_hotel_type | text | 适用酒店类型,竖线分隔 | +| document_remark | text | 备注 | + +**索引规划:** + +- `document_id` 唯一索引 +- `document_owner` 普通索引 +- `document_type` 普通索引 +- `document_status` 普通索引 +- `document_embedding_status` 普通索引 +- `document_effect_date` 普通索引 +- `document_expiry_date` 普通索引 + +--- + +## 3. `tbl_document_operation_history` 文档操作历史表 + +**类型:** Base Collection + +| 字段名 | 类型 | 备注 | +| :--- | :--- | :--- | +| doh_id | text | 文档操作历史业务 id,唯一标识符 | +| doh_document_id | text | 关联 `document_id` | +| doh_operation_type | text | 操作类型 | +| doh_user_id | text | 操作人业务 id | +| doh_current_count | number | 本次操作对应次数 | +| doh_remark | text | 备注 | + +**索引规划:** + +- `doh_id` 唯一索引 +- `doh_document_id` 普通索引 +- `doh_user_id` 普通索引 +- `doh_operation_type` 普通索引 diff --git a/pocket-base/bai-api-main.pb.js b/pocket-base/bai-api-main.pb.js index d16ebde..e53ff5e 100644 --- a/pocket-base/bai-api-main.pb.js +++ b/pocket-base/bai-api-main.pb.js @@ -28,5 +28,15 @@ require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/detail.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/create.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/update.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/delete.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/attachment/list.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/attachment/detail.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/attachment/upload.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/attachment/delete.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/list.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/detail.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/create.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/update.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/delete.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document-history/list.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/login.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/profile.js`) diff --git a/pocket-base/bai-web-main.pb.js b/pocket-base/bai-web-main.pb.js index 5ed467d..916f15c 100644 --- a/pocket-base/bai-web-main.pb.js +++ b/pocket-base/bai-web-main.pb.js @@ -1,4 +1,4 @@ require(`${__hooks}/bai_web_pb_hooks/pages/index.js`) -require(`${__hooks}/bai_web_pb_hooks/pages/page-a.js`) -require(`${__hooks}/bai_web_pb_hooks/pages/page-b.js`) +require(`${__hooks}/bai_web_pb_hooks/pages/login.js`) +require(`${__hooks}/bai_web_pb_hooks/pages/document-manage.js`) require(`${__hooks}/bai_web_pb_hooks/pages/dictionary-manage.js`) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/delete.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/delete.js new file mode 100644 index 0000000..7f72323 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/delete.js @@ -0,0 +1,29 @@ +routerAdd('POST', '/api/attachment/delete', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateAttachmentDeleteBody(e) + const data = documentService.deleteAttachment(payload.attachments_id) + + return success(e, '删除附件成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('删除附件失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '删除附件失败', + data: (err && err.data) || {}, + }) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/detail.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/detail.js new file mode 100644 index 0000000..351be84 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/detail.js @@ -0,0 +1,28 @@ +routerAdd('POST', '/api/attachment/detail', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + + const payload = guards.validateAttachmentDetailBody(e) + const data = documentService.getAttachmentDetail(payload.attachments_id) + + return success(e, '查询附件详情成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('查询附件详情失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '查询附件详情失败', + data: (err && err.data) || {}, + }) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/list.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/list.js new file mode 100644 index 0000000..9e31f1c --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/list.js @@ -0,0 +1,30 @@ +routerAdd('POST', '/api/attachment/list', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + + const payload = guards.validateAttachmentListBody(e) + const data = documentService.listAttachments(payload) + + return success(e, '查询附件列表成功', { + items: data, + }) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('查询附件列表失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '查询附件列表失败', + data: (err && err.data) || {}, + }) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/upload.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/upload.js new file mode 100644 index 0000000..886901a --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/attachment/upload.js @@ -0,0 +1,28 @@ +routerAdd('POST', '/api/attachment/upload', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + const authState = guards.requireManagePlatformUser(e) + const payload = guards.validateAttachmentUploadBody(e) + const files = e.findUploadedFiles('attachments_link') || [] + const file = files.length ? files[0] : null + const data = documentService.uploadAttachment(authState.openid, payload, file) + + return success(e, '上传附件成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('上传附件失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '上传附件失败', + data: (err && err.data) || {}, + }) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/document-history/list.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/document-history/list.js new file mode 100644 index 0000000..1d126eb --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/document-history/list.js @@ -0,0 +1,30 @@ +routerAdd('POST', '/api/document-history/list', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + + const payload = guards.validateDocumentHistoryListBody(e) + const data = documentService.listDocumentHistories(payload) + + return success(e, '查询文档操作历史成功', { + items: data, + }) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('查询文档操作历史失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '查询文档操作历史失败', + data: (err && err.data) || {}, + }) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/document/create.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/document/create.js new file mode 100644 index 0000000..a71bc27 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/document/create.js @@ -0,0 +1,29 @@ +routerAdd('POST', '/api/document/create', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + const authState = guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateDocumentMutationBody(e, false) + const data = documentService.createDocument(authState.openid, payload) + + return success(e, '新增文档成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('新增文档失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '新增文档失败', + data: (err && err.data) || {}, + }) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/document/delete.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/document/delete.js new file mode 100644 index 0000000..6bb0acf --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/document/delete.js @@ -0,0 +1,29 @@ +routerAdd('POST', '/api/document/delete', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + const authState = guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateDocumentDeleteBody(e) + const data = documentService.deleteDocument(authState.openid, payload.document_id) + + return success(e, '删除文档成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('删除文档失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '删除文档失败', + data: (err && err.data) || {}, + }) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/document/detail.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/document/detail.js new file mode 100644 index 0000000..80f4754 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/document/detail.js @@ -0,0 +1,28 @@ +routerAdd('POST', '/api/document/detail', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + + const payload = guards.validateDocumentDetailBody(e) + const data = documentService.getDocumentDetail(payload.document_id) + + return success(e, '查询文档详情成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('查询文档详情失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '查询文档详情失败', + data: (err && err.data) || {}, + }) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/document/list.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/document/list.js new file mode 100644 index 0000000..923f059 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/document/list.js @@ -0,0 +1,30 @@ +routerAdd('POST', '/api/document/list', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + + const payload = guards.validateDocumentListBody(e) + const data = documentService.listDocuments(payload) + + return success(e, '查询文档列表成功', { + items: data, + }) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('查询文档列表失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '查询文档列表失败', + data: (err && err.data) || {}, + }) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/document/update.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/document/update.js new file mode 100644 index 0000000..35bbb9c --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/document/update.js @@ -0,0 +1,29 @@ +routerAdd('POST', '/api/document/update', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + const authState = guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateDocumentMutationBody(e, true) + const data = documentService.updateDocument(authState.openid, payload) + + return success(e, '修改文档成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('修改文档失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '修改文档失败', + data: (err && err.data) || {}, + }) + } +}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js index b61c981..513fa31 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js @@ -167,6 +167,112 @@ function validateDictionaryDeleteBody(e) { } } +function validateAttachmentListBody(e) { + const payload = parseBody(e) + + return { + keyword: payload.keyword || '', + status: payload.status || '', + } +} + +function validateAttachmentDetailBody(e) { + const payload = parseBody(e) + if (!payload.attachments_id) { + throw createAppError(400, 'attachments_id 为必填项') + } + + return { + attachments_id: payload.attachments_id, + } +} + +function validateAttachmentDeleteBody(e) { + return validateAttachmentDetailBody(e) +} + +function validateAttachmentUploadBody(e) { + const payload = sanitizePayload(e.requestInfo().body || {}) + + return { + attachments_filename: payload.attachments_filename || '', + attachments_filetype: payload.attachments_filetype || '', + attachments_size: payload.attachments_size || 0, + attachments_md5: payload.attachments_md5 || '', + attachments_ocr: payload.attachments_ocr || '', + attachments_status: payload.attachments_status || 'active', + attachments_remark: payload.attachments_remark || '', + } +} + +function validateDocumentListBody(e) { + const payload = parseBody(e) + + return { + keyword: payload.keyword || '', + status: payload.status || '', + document_type: payload.document_type || '', + } +} + +function validateDocumentDetailBody(e) { + const payload = parseBody(e) + if (!payload.document_id) { + throw createAppError(400, 'document_id 为必填项') + } + + return { + document_id: payload.document_id, + } +} + +function validateDocumentMutationBody(e, isUpdate) { + const payload = parseBody(e) + + if (!payload.document_id && isUpdate) { + throw createAppError(400, 'document_id 为必填项') + } + + return { + document_id: payload.document_id || '', + document_effect_date: payload.document_effect_date || '', + document_expiry_date: payload.document_expiry_date || '', + document_type: payload.document_type || '', + document_title: payload.document_title || '', + document_subtitle: payload.document_subtitle || '', + document_summary: payload.document_summary || '', + document_content: payload.document_content || '', + document_image: payload.document_image || '', + document_video: payload.document_video || '', + document_relation_model: payload.document_relation_model || '', + document_keywords: payload.document_keywords || '', + document_share_count: payload.document_share_count || 0, + document_download_count: payload.document_download_count || 0, + document_favorite_count: payload.document_favorite_count || 0, + document_status: payload.document_status || '', + document_embedding_status: payload.document_embedding_status || '', + document_embedding_error: payload.document_embedding_error || '', + document_embedding_lasttime: payload.document_embedding_lasttime || '', + document_vector_version: payload.document_vector_version || '', + document_product_categories: payload.document_product_categories || '', + document_application_scenarios: payload.document_application_scenarios || '', + document_hotel_type: payload.document_hotel_type || '', + document_remark: payload.document_remark || '', + } +} + +function validateDocumentDeleteBody(e) { + return validateDocumentDetailBody(e) +} + +function validateDocumentHistoryListBody(e) { + const payload = parseBody(e) + + return { + document_id: payload.document_id || '', + } +} + function requireAuthOpenid(e) { if (!e.auth) { throw createAppError(401, '认证令牌无效或已过期') @@ -236,6 +342,15 @@ module.exports = { validateDictionaryDetailBody, validateDictionaryMutationBody, validateDictionaryDeleteBody, + validateAttachmentListBody, + validateAttachmentDetailBody, + validateAttachmentDeleteBody, + validateAttachmentUploadBody, + validateDocumentListBody, + validateDocumentDetailBody, + validateDocumentMutationBody, + validateDocumentDeleteBody, + validateDocumentHistoryListBody, requireAuthOpenid, requireAuthUser, duplicateGuard, diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/services/documentService.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/documentService.js new file mode 100644 index 0000000..df2769f --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/documentService.js @@ -0,0 +1,473 @@ +const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`) +const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) +const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`) + +function buildBusinessId(prefix) { + return prefix + '-' + new Date().getTime() + '-' + $security.randomString(6) +} + +function normalizePublicBaseUrl() { + const base = String(env.appBaseUrl || '').replace(/\/+$/, '') + + if (!base) return '/pb' + if (base.toLowerCase().endsWith('/pb')) return base + + return base + '/pb' +} + +function buildFileUrl(collectionName, recordId, filename, download) { + if (!filename) return '' + + const base = normalizePublicBaseUrl() + const url = base + '/api/files/' + encodeURIComponent(collectionName) + '/' + encodeURIComponent(recordId) + '/' + encodeURIComponent(filename) + + return download ? (url + '?download=1') : url +} + +function normalizeDateValue(value) { + const text = String(value || '').replace(/^\s+|\s+$/g, '') + if (!text) return '' + + if (/^\d{4}-\d{2}-\d{2}$/.test(text)) { + return text + ' 00:00:00.000Z' + } + + if (/^\d{4}-\d{2}-\d{2}\s/.test(text) || text.indexOf('T') !== -1) { + return text + } + + throw createAppError(400, '日期字段格式错误') +} + +function normalizeNumberValue(value, fieldName) { + if (value === '' || value === null || typeof value === 'undefined') { + return 0 + } + + const num = Number(value) + if (!Number.isFinite(num)) { + throw createAppError(400, fieldName + ' 必须为数字') + } + + return num +} + +function exportAttachmentRecord(record) { + const storedFilename = record.getString('attachments_link') + + return { + pb_id: record.id, + attachments_id: record.getString('attachments_id'), + attachments_link: storedFilename, + attachments_url: buildFileUrl('tbl_attachments', record.id, storedFilename, false), + attachments_download_url: buildFileUrl('tbl_attachments', record.id, storedFilename, true), + attachments_filename: record.getString('attachments_filename'), + attachments_filetype: record.getString('attachments_filetype'), + attachments_size: record.get('attachments_size'), + attachments_owner: record.getString('attachments_owner'), + attachments_md5: record.getString('attachments_md5'), + attachments_ocr: record.getString('attachments_ocr'), + attachments_status: record.getString('attachments_status'), + attachments_remark: record.getString('attachments_remark'), + created: String(record.created || ''), + updated: String(record.updated || ''), + } +} + +function findAttachmentRecordByAttachmentId(attachmentId) { + if (!attachmentId) return null + + const records = $app.findRecordsByFilter('tbl_attachments', 'attachments_id = {:attachmentsId}', '', 1, 0, { + attachmentsId: attachmentId, + }) + + return records.length ? records[0] : null +} + +function exportDocumentRecord(record) { + const imageAttachmentId = record.getString('document_image') + const videoAttachmentId = record.getString('document_video') + const imageAttachmentRecord = findAttachmentRecordByAttachmentId(imageAttachmentId) + const videoAttachmentRecord = findAttachmentRecordByAttachmentId(videoAttachmentId) + const imageAttachment = imageAttachmentRecord ? exportAttachmentRecord(imageAttachmentRecord) : null + const videoAttachment = videoAttachmentRecord ? exportAttachmentRecord(videoAttachmentRecord) : null + + return { + pb_id: record.id, + document_id: record.getString('document_id'), + document_effect_date: String(record.get('document_effect_date') || ''), + document_expiry_date: String(record.get('document_expiry_date') || ''), + document_type: record.getString('document_type'), + document_title: record.getString('document_title'), + document_subtitle: record.getString('document_subtitle'), + document_summary: record.getString('document_summary'), + document_content: record.getString('document_content'), + document_image: imageAttachmentId, + document_image_url: imageAttachment ? imageAttachment.attachments_url : '', + document_image_attachment: imageAttachment, + document_video: videoAttachmentId, + document_video_url: videoAttachment ? videoAttachment.attachments_url : '', + document_video_attachment: videoAttachment, + document_owner: record.getString('document_owner'), + document_relation_model: record.getString('document_relation_model'), + document_keywords: record.getString('document_keywords'), + document_share_count: record.get('document_share_count'), + document_download_count: record.get('document_download_count'), + document_favorite_count: record.get('document_favorite_count'), + document_status: record.getString('document_status'), + document_embedding_status: record.getString('document_embedding_status'), + document_embedding_error: record.getString('document_embedding_error'), + document_embedding_lasttime: String(record.get('document_embedding_lasttime') || ''), + document_vector_version: record.getString('document_vector_version'), + document_product_categories: record.getString('document_product_categories'), + document_application_scenarios: record.getString('document_application_scenarios'), + document_hotel_type: record.getString('document_hotel_type'), + document_remark: record.getString('document_remark'), + created: String(record.created || ''), + updated: String(record.updated || ''), + } +} + +function findDocumentRecordByDocumentId(documentId) { + const records = $app.findRecordsByFilter('tbl_document', 'document_id = {:documentId}', '', 1, 0, { + documentId: documentId, + }) + + return records.length ? records[0] : null +} + +function exportHistoryRecord(record) { + return { + pb_id: record.id, + doh_id: record.getString('doh_id'), + doh_document_id: record.getString('doh_document_id'), + doh_operation_type: record.getString('doh_operation_type'), + doh_user_id: record.getString('doh_user_id'), + doh_current_count: record.get('doh_current_count'), + doh_remark: record.getString('doh_remark'), + created: String(record.created || ''), + updated: String(record.updated || ''), + } +} + +function createHistoryRecord(txApp, payload) { + const collection = txApp.findCollectionByNameOrId('tbl_document_operation_history') + const record = new Record(collection) + + record.set('doh_id', buildBusinessId('DOH')) + record.set('doh_document_id', payload.documentId) + record.set('doh_operation_type', payload.operationType) + record.set('doh_user_id', payload.userOpenid || '') + record.set('doh_current_count', normalizeNumberValue(payload.currentCount, 'doh_current_count')) + record.set('doh_remark', payload.remark || '') + + txApp.save(record) +} + +function ensureAttachmentExists(attachmentId, fieldName) { + if (!attachmentId) return + + const record = findAttachmentRecordByAttachmentId(attachmentId) + if (!record) { + throw createAppError(400, fieldName + ' 对应的附件不存在') + } +} + +function listAttachments(payload) { + const allRecords = $app.findRecordsByFilter('tbl_attachments', '', '', 500, 0) + const keyword = String(payload.keyword || '').toLowerCase() + const status = String(payload.status || '') + const result = [] + + for (let i = 0; i < allRecords.length; i += 1) { + const item = exportAttachmentRecord(allRecords[i]) + const matchedKeyword = !keyword + || item.attachments_id.toLowerCase().indexOf(keyword) !== -1 + || item.attachments_filename.toLowerCase().indexOf(keyword) !== -1 + const matchedStatus = !status || item.attachments_status === status + + if (matchedKeyword && matchedStatus) { + result.push(item) + } + } + + return result +} + +function getAttachmentDetail(attachmentId) { + const record = findAttachmentRecordByAttachmentId(attachmentId) + if (!record) { + throw createAppError(404, '未找到对应附件') + } + + return exportAttachmentRecord(record) +} + +function uploadAttachment(userOpenid, payload, file) { + if (!file) { + throw createAppError(400, 'attachments_link 为必填文件') + } + + const collection = $app.findCollectionByNameOrId('tbl_attachments') + const record = new Record(collection) + + record.set('attachments_id', buildBusinessId('ATT')) + record.set('attachments_link', file) + record.set('attachments_filename', payload.attachments_filename || '') + record.set('attachments_filetype', payload.attachments_filetype || '') + record.set('attachments_size', normalizeNumberValue(payload.attachments_size, 'attachments_size')) + record.set('attachments_owner', userOpenid || '') + record.set('attachments_md5', payload.attachments_md5 || '') + record.set('attachments_ocr', payload.attachments_ocr || '') + record.set('attachments_status', payload.attachments_status || 'active') + record.set('attachments_remark', payload.attachments_remark || '') + + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '上传附件失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('附件上传成功', { + attachments_id: record.getString('attachments_id'), + attachments_owner: userOpenid || '', + }) + + return exportAttachmentRecord(record) +} + +function deleteAttachment(attachmentId) { + const record = findAttachmentRecordByAttachmentId(attachmentId) + if (!record) { + throw createAppError(404, '未找到待删除的附件') + } + + const quotedAttachmentId = '"' + attachmentId.replace(/"/g, '\\"') + '"' + const usedByDocument = $app.findRecordsByFilter('tbl_document', 'document_image = ' + quotedAttachmentId + ' || document_video = ' + quotedAttachmentId, '', 1, 0) + if (usedByDocument.length) { + throw createAppError(400, '附件已被文档引用,无法删除') + } + + try { + $app.delete(record) + } catch (err) { + throw createAppError(400, '删除附件失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('附件删除成功', { + attachments_id: attachmentId, + }) + + return { + attachments_id: attachmentId, + } +} + +function listDocuments(payload) { + const allRecords = $app.findRecordsByFilter('tbl_document', '', '', 500, 0) + const keyword = String(payload.keyword || '').toLowerCase() + const status = String(payload.status || '') + const type = String(payload.document_type || '') + const result = [] + + for (let i = 0; i < allRecords.length; i += 1) { + const item = exportDocumentRecord(allRecords[i]) + const matchedKeyword = !keyword + || item.document_id.toLowerCase().indexOf(keyword) !== -1 + || item.document_title.toLowerCase().indexOf(keyword) !== -1 + || item.document_subtitle.toLowerCase().indexOf(keyword) !== -1 + || item.document_summary.toLowerCase().indexOf(keyword) !== -1 + || item.document_keywords.toLowerCase().indexOf(keyword) !== -1 + const matchedStatus = !status || item.document_status === status + const matchedType = !type || item.document_type === type + + if (matchedKeyword && matchedStatus && matchedType) { + result.push(item) + } + } + + result.sort(function (a, b) { + return String(b.updated || '').localeCompare(String(a.updated || '')) + }) + + return result +} + +function getDocumentDetail(documentId) { + const record = findDocumentRecordByDocumentId(documentId) + if (!record) { + throw createAppError(404, '未找到对应文档') + } + + return exportDocumentRecord(record) +} + +function createDocument(userOpenid, payload) { + ensureAttachmentExists(payload.document_image, 'document_image') + ensureAttachmentExists(payload.document_video, 'document_video') + + const targetDocumentId = payload.document_id || buildBusinessId('DOC') + const duplicated = findDocumentRecordByDocumentId(targetDocumentId) + if (duplicated) { + throw createAppError(400, 'document_id 已存在') + } + + return $app.runInTransaction(function (txApp) { + const collection = txApp.findCollectionByNameOrId('tbl_document') + const record = new Record(collection) + + record.set('document_id', targetDocumentId) + record.set('document_effect_date', normalizeDateValue(payload.document_effect_date)) + record.set('document_expiry_date', normalizeDateValue(payload.document_expiry_date)) + record.set('document_type', payload.document_type || '') + record.set('document_title', payload.document_title || '') + record.set('document_subtitle', payload.document_subtitle || '') + record.set('document_summary', payload.document_summary || '') + record.set('document_content', payload.document_content || '') + record.set('document_image', payload.document_image || '') + record.set('document_video', payload.document_video || '') + record.set('document_owner', userOpenid || '') + record.set('document_relation_model', payload.document_relation_model || '') + record.set('document_keywords', payload.document_keywords || '') + record.set('document_share_count', normalizeNumberValue(payload.document_share_count, 'document_share_count')) + record.set('document_download_count', normalizeNumberValue(payload.document_download_count, 'document_download_count')) + record.set('document_favorite_count', normalizeNumberValue(payload.document_favorite_count, 'document_favorite_count')) + record.set('document_status', payload.document_status || 'active') + record.set('document_embedding_status', payload.document_embedding_status || 'pending') + record.set('document_embedding_error', payload.document_embedding_error || '') + record.set('document_embedding_lasttime', normalizeDateValue(payload.document_embedding_lasttime)) + record.set('document_vector_version', payload.document_vector_version || '') + record.set('document_product_categories', payload.document_product_categories || '') + record.set('document_application_scenarios', payload.document_application_scenarios || '') + record.set('document_hotel_type', payload.document_hotel_type || '') + record.set('document_remark', payload.document_remark || '') + + txApp.save(record) + + createHistoryRecord(txApp, { + documentId: record.getString('document_id'), + operationType: 'create', + userOpenid: userOpenid, + currentCount: 1, + remark: '文档创建', + }) + + return exportDocumentRecord(record) + }) +} + +function updateDocument(userOpenid, payload) { + const record = findDocumentRecordByDocumentId(payload.document_id) + if (!record) { + throw createAppError(404, '未找到待修改的文档') + } + + ensureAttachmentExists(payload.document_image, 'document_image') + ensureAttachmentExists(payload.document_video, 'document_video') + + return $app.runInTransaction(function (txApp) { + const target = txApp.findRecordById('tbl_document', record.id) + + target.set('document_effect_date', normalizeDateValue(payload.document_effect_date)) + target.set('document_expiry_date', normalizeDateValue(payload.document_expiry_date)) + target.set('document_type', payload.document_type || '') + target.set('document_title', payload.document_title || '') + target.set('document_subtitle', payload.document_subtitle || '') + target.set('document_summary', payload.document_summary || '') + target.set('document_content', payload.document_content || '') + target.set('document_image', payload.document_image || '') + target.set('document_video', payload.document_video || '') + target.set('document_relation_model', payload.document_relation_model || '') + target.set('document_keywords', payload.document_keywords || '') + target.set('document_share_count', normalizeNumberValue(payload.document_share_count, 'document_share_count')) + target.set('document_download_count', normalizeNumberValue(payload.document_download_count, 'document_download_count')) + target.set('document_favorite_count', normalizeNumberValue(payload.document_favorite_count, 'document_favorite_count')) + target.set('document_status', payload.document_status || '') + target.set('document_embedding_status', payload.document_embedding_status || '') + target.set('document_embedding_error', payload.document_embedding_error || '') + target.set('document_embedding_lasttime', normalizeDateValue(payload.document_embedding_lasttime)) + target.set('document_vector_version', payload.document_vector_version || '') + target.set('document_product_categories', payload.document_product_categories || '') + target.set('document_application_scenarios', payload.document_application_scenarios || '') + target.set('document_hotel_type', payload.document_hotel_type || '') + target.set('document_remark', payload.document_remark || '') + + txApp.save(target) + + createHistoryRecord(txApp, { + documentId: target.getString('document_id'), + operationType: 'update', + userOpenid: userOpenid, + currentCount: 1, + remark: '文档更新', + }) + + return exportDocumentRecord(target) + }) +} + +function deleteDocument(userOpenid, documentId) { + const record = findDocumentRecordByDocumentId(documentId) + if (!record) { + throw createAppError(404, '未找到待删除的文档') + } + + return $app.runInTransaction(function (txApp) { + createHistoryRecord(txApp, { + documentId: documentId, + operationType: 'delete', + userOpenid: userOpenid, + currentCount: 1, + remark: '文档删除', + }) + + const target = txApp.findRecordById('tbl_document', record.id) + txApp.delete(target) + + logger.info('文档删除成功', { + document_id: documentId, + }) + + return { + document_id: documentId, + } + }) +} + +function listDocumentHistories(payload) { + const allRecords = payload.document_id + ? $app.findRecordsByFilter('tbl_document_operation_history', 'doh_document_id = {:documentId}', '', 500, 0, { + documentId: payload.document_id, + }) + : $app.findRecordsByFilter('tbl_document_operation_history', '', '', 500, 0) + + const result = [] + for (let i = 0; i < allRecords.length; i += 1) { + result.push(exportHistoryRecord(allRecords[i])) + } + + result.sort(function (a, b) { + return String(b.created || '').localeCompare(String(a.created || '')) + }) + + return result +} + +module.exports = { + listAttachments, + getAttachmentDetail, + uploadAttachment, + deleteAttachment, + listDocuments, + getDocumentDetail, + createDocument, + updateDocument, + deleteDocument, + listDocumentHistories, +} diff --git a/pocket-base/bai_web_pb_hooks/pages/document-manage.js b/pocket-base/bai_web_pb_hooks/pages/document-manage.js new file mode 100644 index 0000000..3b2f416 --- /dev/null +++ b/pocket-base/bai_web_pb_hooks/pages/document-manage.js @@ -0,0 +1,429 @@ +routerAdd('GET', '/manage/document-manage', function (e) { + const html = ` + + + + + 文档管理 + + + + +
+
+

文档管理

+

页面会先把文件上传到 tbl_attachments,然后把返回的 attachments_id 写入 tbl_document.document_imagedocument_video。文档列表会直接显示 PocketBase 文件流链接。

+
+ 返回主页 + 登录页 + + +
+
+
+ +
+

新增文档

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
例如:安装|配置|排障
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+

文档列表

+
+ + + + + + + + + + + + + + +
document_id标题类型/状态附件链接更新时间操作
暂无数据,请先刷新列表。
+
+
+
+ + + +` + + return e.html(200, html) +}) diff --git a/pocket-base/bai_web_pb_hooks/pages/index.js b/pocket-base/bai_web_pb_hooks/pages/index.js index ee06520..3e3e970 100644 --- a/pocket-base/bai_web_pb_hooks/pages/index.js +++ b/pocket-base/bai_web_pb_hooks/pages/index.js @@ -16,41 +16,31 @@ routerAdd('GET', '/manage', function (e) {

管理主页

-

该页面仅供已登录的 ManagePlatform 用户使用。你可以从这里跳转到各个管理子页面。

-
如未登录或 token 无效,系统会自动跳转到登录页。

字典管理

-

维护 tbl_system_dict,支持模糊查询、行内编辑、枚举项弹窗编辑、新增和删除。

进入字典管理
-

登录页

-

登录入口页。若已登录,访问该页会自动跳回管理主页。

- 打开登录页 -
-
-
+
@@ -68,4 +58,4 @@ routerAdd('GET', '/manage', function (e) { ` return e.html(200, html) -}) \ No newline at end of file +}) diff --git a/pocket-base/bai_web_pb_hooks/pages/page-a.js b/pocket-base/bai_web_pb_hooks/pages/login.js similarity index 99% rename from pocket-base/bai_web_pb_hooks/pages/page-a.js rename to pocket-base/bai_web_pb_hooks/pages/login.js index ce5426a..5625338 100644 --- a/pocket-base/bai_web_pb_hooks/pages/page-a.js +++ b/pocket-base/bai_web_pb_hooks/pages/login.js @@ -155,4 +155,3 @@ function renderLoginPage(e) { } routerAdd('GET', '/manage/login', renderLoginPage) -routerAdd('GET', '/manage/page-a', renderLoginPage) diff --git a/pocket-base/bai_web_pb_hooks/pages/page-b.js b/pocket-base/bai_web_pb_hooks/pages/page-b.js deleted file mode 100644 index 7df38ab..0000000 --- a/pocket-base/bai_web_pb_hooks/pages/page-b.js +++ /dev/null @@ -1,62 +0,0 @@ -routerAdd('GET', '/manage/page-b', function (e) { - const html = ` - - - - - 页面二 - - - - -
-
-

页面二

-

这里是 page-b 页面。当前用于验证 PocketBase hooks 页面路由是否已经正确注册,并可从 manage 首页完成跳转。

- -
-
- -` - - return e.html(200, html) -}) \ No newline at end of file diff --git a/pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md b/pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md index cd7832a..7d252bc 100644 --- a/pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md +++ b/pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md @@ -6,7 +6,7 @@ ## 范围 -本次变更覆盖 `pocket-base/` 下 PocketBase hooks 项目的微信登录、平台注册/登录、资料更新、token 刷新、认证落库、错误可观测性、索引策略、字典管理、页面辅助操作、健康检查版本探针、OpenAPI 鉴权与统一响应规范。 +本次变更覆盖 `pocket-base/` 下 PocketBase hooks 项目的微信登录、平台注册/登录、资料更新、token 刷新、认证落库、错误可观测性、索引策略、字典管理、附件管理、文档管理、文档操作历史、页面辅助操作、健康检查版本探针、OpenAPI 鉴权与统一响应规范。 --- @@ -144,6 +144,16 @@ - `POST /api/dictionary/create` - `POST /api/dictionary/update` - `POST /api/dictionary/delete` +- `POST /api/attachment/list` +- `POST /api/attachment/detail` +- `POST /api/attachment/upload` +- `POST /api/attachment/delete` +- `POST /api/document/list` +- `POST /api/document/detail` +- `POST /api/document/create` +- `POST /api/document/update` +- `POST /api/document/delete` +- `POST /api/document-history/list` 其中平台用户链路补充为: @@ -209,16 +219,100 @@ - `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 已统一改为 JSON 字符串持久化,其中 `dict_word_sort_order` 已改为 `text` 类型。 - 查询时统一聚合为:`items: [{ enum, description, sortOrder }]` +### 附件管理接口 + +新增 `attachment` 分类接口,统一要求平台管理用户访问: + +- `POST /api/attachment/list` + - 支持按 `attachments_id`、`attachments_filename` 模糊搜索 + - 支持按 `attachments_status` 过滤 + - 返回附件元数据以及 PocketBase 文件流链接 `attachments_url` +- `POST /api/attachment/detail` + - 按 `attachments_id` 查询单个附件 + - 返回文件流链接与下载链接 +- `POST /api/attachment/upload` + - 使用 `multipart/form-data` + - 文件字段固定为 `attachments_link` + - 上传成功后自动生成 `attachments_id` + - 自动写入 `attachments_owner = 当前用户 openid` +- `POST /api/attachment/delete` + - 按 `attachments_id` 真删除附件 + - 若该附件已被 `tbl_document.document_image` 或 `document_video` 引用,则拒绝删除 + +说明: + +- `tbl_attachments.attachments_link` 为 PocketBase `file` 字段,保存实际文件本体。 +- 对外查询时会额外补充: + - `attachments_url` + - `attachments_download_url` + +### 文档管理接口 + +新增 `document` 分类接口,统一要求平台管理用户访问: + +- `POST /api/document/list` + - 支持按 `document_id`、`document_title`、`document_subtitle`、`document_summary`、`document_keywords` 模糊搜索 + - 支持按 `document_status`、`document_type` 过滤 + - 返回时会自动联查 `tbl_attachments` + - 额外补充: + - `document_image_url` + - `document_video_url` + - `document_image_attachment` + - `document_video_attachment` +- `POST /api/document/detail` + - 按 `document_id` 查询单条文档 + - 返回与附件表联动解析后的文件流链接 +- `POST /api/document/create` + - 新增文档 + - `document_id` 可不传,由服务端自动生成 + - `document_image`、`document_video` 必须传入已存在的 `attachments_id` + - 成功后会写入一条文档操作历史,类型为 `create` +- `POST /api/document/update` + - 按 `document_id` 更新文档 + - 若传入附件字段,则会校验对应 `attachments_id` 是否存在 + - 成功后会写入一条文档操作历史,类型为 `update` +- `POST /api/document/delete` + - 按 `document_id` 真删除文档 + - 删除前会写入一条文档操作历史,类型为 `delete` + +说明: + +- `document_image`、`document_video` 当前保存的是 `attachments_id`,不是 PocketBase 文件字段。 +- 文档查询时通过 `attachments_id -> tbl_attachments` 反查实际文件,并返回可直接访问的数据流链接。 +- `document_owner` 语义为“上传者 openid”。 + +### 文档操作历史接口 + +新增 `document-history` 分类接口,统一要求平台管理用户访问: + +- `POST /api/document-history/list` + - 不传 `document_id` 时返回全部文档历史 + - 传入 `document_id` 时仅返回该文档历史 + - 结果按创建时间倒序排列 + +说明: + +- 操作历史表为 `tbl_document_operation_history` +- 当前由文档新增、修改、删除接口自动写入 +- 主要字段为: + - `doh_document_id` + - `doh_operation_type` + - `doh_user_id` + - `doh_current_count` + - `doh_remark` + --- ## 七、页面与运维辅助能力新增 ### 1. PocketBase 页面 -新增页面: +当前页面入口: -- `/web` -- `/web/dictionary-manage` +- `/pb/manage` +- `/pb/manage/login` +- `/pb/manage/dictionary-manage` +- `/pb/manage/document-manage` 页面能力: @@ -231,6 +325,22 @@ - 弹窗编辑枚举项 - 新增 / 删除字典 - 返回主页 +- 文档管理页支持: + - 先上传附件到 `tbl_attachments` + - 再把返回的 `attachments_id` 写入 `tbl_document.document_image` / `document_video` + - 新增文档 + - 查询文档列表 + - 直接展示 PocketBase 文件流链接 + - 删除文档 + +说明: + +- 原页面 `page-b.js` 已替换为 `document-manage.js` +- 页面实际走的接口链路为: + - `/api/attachment/upload` + - `/api/document/create` + - `/api/document/list` + - `/api/document/delete` ### 2. 健康检查版本探针 diff --git a/pocket-base/spec/openapi.yaml b/pocket-base/spec/openapi.yaml index c0d15fb..e133986 100644 --- a/pocket-base/spec/openapi.yaml +++ b/pocket-base/spec/openapi.yaml @@ -9,7 +9,7 @@ info: 请在 Apifox 环境中统一设置全局 Header:`Authorization: Bearer {{token}}`。 version: 1.0.0 servers: - - url: https://bai-api.blv-oa.com/pb + - url: https://bai-api.blv-oa.com description: 生产环境 - url: http://localhost:8090 description: PocketBase 本地环境 @@ -22,6 +22,12 @@ tags: description: 面向平台用户的认证接口;平台用户会生成 GUID 并写入统一 `openid` 字段。 - name: 字典管理 description: 面向 ManagePlatform 用户的系统字典维护接口。 + - name: 附件管理 + description: 面向 ManagePlatform 用户的附件上传、查询与删除接口。 + - name: 文档管理 + description: 面向 ManagePlatform 用户的文档新增、查询、修改、删除接口;查询时会自动返回关联附件的 PocketBase 文件流链接。 + - name: 文档历史 + description: 面向 ManagePlatform 用户的文档操作历史查询接口。 components: schemas: ApiResponse: @@ -358,8 +364,286 @@ components: dict_name: type: string example: 用户状态 + AttachmentRecord: + type: object + properties: + pb_id: + type: string + attachments_id: + type: string + attachments_link: + type: string + description: PocketBase 实际存储的文件名 + attachments_url: + type: string + description: 附件文件流访问链接 + attachments_download_url: + type: string + description: 附件下载链接 + attachments_filename: + type: string + attachments_filetype: + type: string + attachments_size: + type: number + attachments_owner: + type: string + attachments_md5: + type: string + attachments_ocr: + type: string + attachments_status: + type: string + attachments_remark: + type: string + created: + type: string + updated: + type: string + AttachmentListRequest: + type: object + properties: + keyword: + type: string + description: 对 `attachments_id`、`attachments_filename` 的模糊搜索关键字 + example: 手册 + status: + type: string + description: 按附件状态过滤 + example: active + AttachmentDetailRequest: + type: object + required: [attachments_id] + properties: + attachments_id: + type: string + example: ATT-1743037200000-abc123 + AttachmentUploadRequest: + type: object + required: [attachments_link] + properties: + attachments_link: + type: string + format: binary + description: 要上传到 `tbl_attachments` 的单个文件 + attachments_filename: + type: string + description: 原始文件名;不传时可由前端直接使用文件名 + attachments_filetype: + type: string + description: 文件 MIME 类型 + attachments_size: + type: number + description: 文件大小 + attachments_md5: + type: string + attachments_ocr: + type: string + attachments_status: + type: string + example: active + attachments_remark: + type: string + DocumentRecord: + type: object + properties: + pb_id: + type: string + document_id: + type: string + document_effect_date: + type: string + document_expiry_date: + type: string + document_type: + type: string + document_title: + type: string + document_subtitle: + type: string + document_summary: + type: string + document_content: + type: string + document_image: + type: string + description: 关联的 `attachments_id` + document_image_url: + type: string + description: 根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接 + document_image_attachment: + allOf: + - $ref: '#/components/schemas/AttachmentRecord' + nullable: true + document_video: + type: string + description: 关联的 `attachments_id` + document_video_url: + type: string + description: 根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接 + document_video_attachment: + allOf: + - $ref: '#/components/schemas/AttachmentRecord' + nullable: true + document_owner: + type: string + description: 上传者 openid + document_relation_model: + type: string + document_keywords: + type: string + description: 多值字段,使用 `|` 分隔 + document_share_count: + type: number + document_download_count: + type: number + document_favorite_count: + type: number + document_status: + type: string + document_embedding_status: + type: string + document_embedding_error: + type: string + document_embedding_lasttime: + type: string + document_vector_version: + type: string + document_product_categories: + type: string + description: 多值字段,使用 `|` 分隔 + document_application_scenarios: + type: string + description: 多值字段,使用 `|` 分隔 + document_hotel_type: + type: string + description: 多值字段,使用 `|` 分隔 + document_remark: + type: string + created: + type: string + updated: + type: string + DocumentListRequest: + type: object + properties: + keyword: + type: string + description: 对 `document_id`、`document_title`、`document_subtitle`、`document_summary`、`document_keywords` 的模糊搜索关键字 + example: 安装 + status: + type: string + example: active + document_type: + type: string + example: 说明书 + DocumentDetailRequest: + type: object + required: [document_id] + properties: + document_id: + type: string + example: DOC-1743037200000-abc123 + DocumentMutationRequest: + type: object + properties: + document_id: + type: string + description: 创建时可不传,由服务端自动生成;更新时必填 + example: DOC-1743037200000-abc123 + document_effect_date: + type: string + description: 支持 `YYYY-MM-DD` 或 PocketBase 可识别日期时间字符串 + example: 2026-03-27 + document_expiry_date: + type: string + description: 支持 `YYYY-MM-DD` 或 PocketBase 可识别日期时间字符串 + example: 2027-03-27 + document_type: + type: string + document_title: + type: string + document_subtitle: + type: string + document_summary: + type: string + document_content: + type: string + document_image: + type: string + description: 图片附件的 `attachments_id` + document_video: + type: string + description: 视频附件的 `attachments_id` + document_relation_model: + type: string + document_keywords: + type: string + description: 多值字段,使用 `|` 分隔 + document_share_count: + type: number + document_download_count: + type: number + document_favorite_count: + type: number + document_status: + type: string + document_embedding_status: + type: string + document_embedding_error: + type: string + document_embedding_lasttime: + type: string + document_vector_version: + type: string + document_product_categories: + type: string + description: 多值字段,使用 `|` 分隔 + document_application_scenarios: + type: string + description: 多值字段,使用 `|` 分隔 + document_hotel_type: + type: string + description: 多值字段,使用 `|` 分隔 + document_remark: + type: string + DocumentDeleteRequest: + type: object + required: [document_id] + properties: + document_id: + type: string + example: DOC-1743037200000-abc123 + DocumentHistoryRecord: + type: object + properties: + pb_id: + type: string + doh_id: + type: string + doh_document_id: + type: string + doh_operation_type: + type: string + doh_user_id: + type: string + doh_current_count: + type: number + doh_remark: + type: string + created: + type: string + updated: + type: string + DocumentHistoryListRequest: + type: object + properties: + document_id: + type: string + description: 可选;传入时仅查询指定文档的操作历史 + example: DOC-1743037200000-abc123 paths: - /api/system/test-helloworld: + /pb/api/system/test-helloworld: post: tags: [系统] summary: HelloWorld 测试接口 @@ -375,7 +659,7 @@ paths: properties: data: $ref: '#/components/schemas/HelloWorldData' - /api/system/health: + /pb/api/system/health: post: tags: [系统] summary: 健康检查 @@ -391,7 +675,7 @@ paths: properties: data: $ref: '#/components/schemas/HealthData' - /api/system/users-count: + /pb/api/system/users-count: post: tags: [系统] summary: 查询用户总数 @@ -408,7 +692,7 @@ paths: properties: data: $ref: '#/components/schemas/UsersCountData' - /api/system/refresh-token: + /pb/api/system/refresh-token: post: tags: [系统] summary: 刷新系统认证 token @@ -451,7 +735,7 @@ paths: description: 请求体必须为 application/json '429': description: 重复请求过于频繁 - /api/wechat/login: + /pb/api/wechat/login: post: tags: [微信认证] summary: 微信登录/注册合一 @@ -483,7 +767,7 @@ paths: description: 重复请求过于频繁 '500': description: 保存 auth 用户失败或服务端内部错误 - /api/platform/register: + /pb/api/platform/register: post: tags: [平台认证] summary: 平台用户注册 @@ -513,7 +797,7 @@ paths: description: 请求体必须为 application/json '429': description: 重复请求过于频繁 - /api/platform/login: + /pb/api/platform/login: post: tags: [平台认证] summary: 平台用户登录 @@ -575,7 +859,7 @@ paths: description: 请求体必须为 application/json '429': description: 重复请求过于频繁 - /api/wechat/profile: + /pb/api/wechat/profile: post: tags: [微信认证] summary: 更新微信用户资料 @@ -604,7 +888,7 @@ paths: description: token 无效或当前 auth record 缺少统一身份字段 openid '400': description: 参数错误、手机号已被注册或资料更新失败 - /api/dictionary/list: + /pb/api/dictionary/list: post: tags: [字典管理] summary: 查询字典列表 @@ -640,7 +924,7 @@ paths: description: 非 ManagePlatform 用户无权访问 '415': description: 请求体必须为 application/json - /api/dictionary/detail: + /pb/api/dictionary/detail: post: tags: [字典管理] summary: 查询指定字典 @@ -675,7 +959,7 @@ paths: description: 未找到对应字典 '415': description: 请求体必须为 application/json - /api/dictionary/create: + /pb/api/dictionary/create: post: tags: [字典管理] summary: 新增字典 @@ -711,7 +995,7 @@ paths: description: 请求体必须为 application/json '429': description: 重复请求过于频繁 - /api/dictionary/update: + /pb/api/dictionary/update: post: tags: [字典管理] summary: 修改字典 @@ -748,7 +1032,7 @@ paths: description: 请求体必须为 application/json '429': description: 重复请求过于频繁 - /api/dictionary/delete: + /pb/api/dictionary/delete: post: tags: [字典管理] summary: 删除字典 @@ -788,3 +1072,370 @@ paths: description: 请求体必须为 application/json '429': description: 重复请求过于频繁 + /pb/api/attachment/list: + post: + tags: [附件管理] + summary: 查询附件列表 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 支持按 `attachments_id`、`attachments_filename` 模糊搜索,并可按 `attachments_status` 过滤。 + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentListRequest' + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/AttachmentRecord' + '401': + description: token 无效或已过期 + '403': + description: 非 ManagePlatform 用户无权访问 + '415': + description: 请求体必须为 application/json + /pb/api/attachment/detail: + post: + tags: [附件管理] + summary: 查询附件详情 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 按 `attachments_id` 查询单条附件,并返回 PocketBase 文件流链接与下载链接。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentDetailRequest' + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/AttachmentRecord' + '400': + description: 参数错误 + '401': + description: token 无效或已过期 + '403': + description: 非 ManagePlatform 用户无权访问 + '404': + description: 未找到对应附件 + '415': + description: 请求体必须为 application/json + /pb/api/attachment/upload: + post: + tags: [附件管理] + summary: 上传附件 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 使用 `multipart/form-data` 上传单个文件到 `tbl_attachments`,服务端会自动生成 `attachments_id`, + 并返回可直接访问的 PocketBase 文件流链接。 + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/AttachmentUploadRequest' + responses: + '200': + description: 上传成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/AttachmentRecord' + '400': + description: 参数错误、缺少文件或附件保存失败 + '401': + description: token 无效或已过期 + '403': + description: 非 ManagePlatform 用户无权访问 + /pb/api/attachment/delete: + post: + tags: [附件管理] + summary: 删除附件 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image` 或 `document_video` 引用,则拒绝删除。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentDetailRequest' + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + type: object + properties: + attachments_id: + type: string + '400': + description: 参数错误、附件已被文档引用或删除失败 + '401': + description: token 无效或已过期 + '403': + description: 非 ManagePlatform 用户无权访问 + '404': + description: 未找到待删除附件 + '415': + description: 请求体必须为 application/json + '429': + description: 重复请求过于频繁 + /pb/api/document/list: + post: + tags: [文档管理] + summary: 查询文档列表 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。 + 返回结果会自动根据 `document_image`、`document_video` 关联 `tbl_attachments`, + 额外补充 `document_image_url`、`document_video_url` 以及对应附件对象。 + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentListRequest' + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/DocumentRecord' + '401': + description: token 无效或已过期 + '403': + description: 非 ManagePlatform 用户无权访问 + '415': + description: 请求体必须为 application/json + /pb/api/document/detail: + post: + tags: [文档管理] + summary: 查询文档详情 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 按 `document_id` 查询单条文档,并返回与附件表联动解析后的文件流链接。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentDetailRequest' + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/DocumentRecord' + '400': + description: 参数错误 + '401': + description: token 无效或已过期 + '403': + description: 非 ManagePlatform 用户无权访问 + '404': + description: 未找到对应文档 + '415': + description: 请求体必须为 application/json + /pb/api/document/create: + post: + tags: [文档管理] + summary: 新增文档 + description: | + 仅允许 `ManagePlatform` 用户访问。 + `document_id` 可选;未传时服务端自动生成。 + `document_image`、`document_video` 需传入已存在于 `tbl_attachments` 的 `attachments_id`。 + 成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `create`。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentMutationRequest' + responses: + '200': + description: 新增成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/DocumentRecord' + '400': + description: 参数错误、附件不存在或文档创建失败 + '401': + description: token 无效或已过期 + '403': + description: 非 ManagePlatform 用户无权访问 + '415': + description: 请求体必须为 application/json + '429': + description: 重复请求过于频繁 + /pb/api/document/update: + post: + tags: [文档管理] + summary: 修改文档 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 按 `document_id` 定位现有文档并更新;若传入 `document_image`、`document_video`,则必须是已存在的 `attachments_id`。 + 成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentMutationRequest' + responses: + '200': + description: 修改成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/DocumentRecord' + '400': + description: 参数错误、附件不存在或修改失败 + '401': + description: token 无效或已过期 + '403': + description: 非 ManagePlatform 用户无权访问 + '404': + description: 未找到待修改文档 + '415': + description: 请求体必须为 application/json + '429': + description: 重复请求过于频繁 + /pb/api/document/delete: + post: + tags: [文档管理] + summary: 删除文档 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 按 `document_id` 真删除文档,并在删除前同步写入一条 `tbl_document_operation_history`,操作类型为 `delete`。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentDeleteRequest' + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + type: object + properties: + document_id: + type: string + '400': + description: 参数错误或删除失败 + '401': + description: token 无效或已过期 + '403': + description: 非 ManagePlatform 用户无权访问 + '404': + description: 未找到待删除文档 + '415': + description: 请求体必须为 application/json + '429': + description: 重复请求过于频繁 + /pb/api/document-history/list: + post: + tags: [文档历史] + summary: 查询文档操作历史 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 若 body 传入 `document_id`,则仅查询该文档的历史;否则返回全部文档操作历史,按创建时间倒序排列。 + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentHistoryListRequest' + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/DocumentHistoryRecord' + '401': + description: token 无效或已过期 + '403': + description: 非 ManagePlatform 用户无权访问 + '415': + description: 请求体必须为 application/json diff --git a/script/package.json b/script/package.json index 6edd191..d2c1350 100644 --- a/script/package.json +++ b/script/package.json @@ -5,7 +5,8 @@ "main": "pocketbase.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "init:newpb": "node pocketbase.newpb.js" + "init:newpb": "node pocketbase.newpb.js", + "init:documents": "node pocketbase.documents.js" }, "keywords": [], "author": "", diff --git a/script/pocketbase.documents.js b/script/pocketbase.documents.js new file mode 100644 index 0000000..bf38589 --- /dev/null +++ b/script/pocketbase.documents.js @@ -0,0 +1,272 @@ +import { createRequire } from 'module'; +import PocketBase from 'pocketbase'; + +const require = createRequire(import.meta.url); + +let runtimeConfig = {}; +try { + runtimeConfig = require('../pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js'); +} catch (_error) { + runtimeConfig = {}; +} + +const PB_URL = (process.env.PB_URL || 'https://bai-api.blv-oa.com/pb').replace(/\/+$/, ''); +const AUTH_TOKEN = process.env.POCKETBASE_AUTH_TOKEN || runtimeConfig.POCKETBASE_AUTH_TOKEN || ''; + +if (!AUTH_TOKEN) { + console.error('❌ 缺少 POCKETBASE_AUTH_TOKEN,无法执行建表。'); + process.exit(1); +} + +const pb = new PocketBase(PB_URL); + +const collections = [ + { + name: 'tbl_attachments', + type: 'base', + fields: [ + { name: 'attachments_id', type: 'text', required: true }, + { name: 'attachments_link', type: 'file', maxSelect: 0, maxSize: 104857600, mimeTypes: [] }, + { name: 'attachments_filename', type: 'text' }, + { name: 'attachments_filetype', type: 'text' }, + { name: 'attachments_size', type: 'number' }, + { name: 'attachments_owner', type: 'text' }, + { name: 'attachments_md5', type: 'text' }, + { name: 'attachments_ocr', type: 'text' }, + { name: 'attachments_status', type: 'text' }, + { name: 'attachments_remark', type: 'text' }, + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_tbl_attachments_attachments_id ON tbl_attachments (attachments_id)', + 'CREATE INDEX idx_tbl_attachments_attachments_owner ON tbl_attachments (attachments_owner)', + 'CREATE INDEX idx_tbl_attachments_attachments_status ON tbl_attachments (attachments_status)', + ], + }, + { + name: 'tbl_document', + type: 'base', + fields: [ + { name: 'document_id', type: 'text', required: true }, + { name: 'document_effect_date', type: 'date' }, + { name: 'document_expiry_date', type: 'date' }, + { name: 'document_type', type: 'text' }, + { name: 'document_title', type: 'text' }, + { name: 'document_subtitle', type: 'text' }, + { name: 'document_summary', type: 'text' }, + { name: 'document_content', type: 'text' }, + { name: 'document_image', type: 'text' }, + { name: 'document_video', type: 'text' }, + { name: 'document_owner', type: 'text' }, + { name: 'document_relation_model', type: 'text' }, + { name: 'document_keywords', type: 'text' }, + { name: 'document_share_count', type: 'number' }, + { name: 'document_download_count', type: 'number' }, + { name: 'document_favorite_count', type: 'number' }, + { name: 'document_status', type: 'text' }, + { name: 'document_embedding_status', type: 'text' }, + { name: 'document_embedding_error', type: 'text' }, + { name: 'document_embedding_lasttime', type: 'date' }, + { name: 'document_vector_version', type: 'text' }, + { name: 'document_product_categories', type: 'text' }, + { name: 'document_application_scenarios', type: 'text' }, + { name: 'document_hotel_type', type: 'text' }, + { name: 'document_remark', type: 'text' }, + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_tbl_document_document_id ON tbl_document (document_id)', + 'CREATE INDEX idx_tbl_document_document_owner ON tbl_document (document_owner)', + 'CREATE INDEX idx_tbl_document_document_type ON tbl_document (document_type)', + 'CREATE INDEX idx_tbl_document_document_status ON tbl_document (document_status)', + 'CREATE INDEX idx_tbl_document_document_embedding_status ON tbl_document (document_embedding_status)', + 'CREATE INDEX idx_tbl_document_document_effect_date ON tbl_document (document_effect_date)', + 'CREATE INDEX idx_tbl_document_document_expiry_date ON tbl_document (document_expiry_date)', + ], + }, + { + name: 'tbl_document_operation_history', + type: 'base', + fields: [ + { name: 'doh_id', type: 'text', required: true }, + { name: 'doh_document_id', type: 'text', required: true }, + { name: 'doh_operation_type', type: 'text' }, + { name: 'doh_user_id', type: 'text' }, + { name: 'doh_current_count', type: 'number' }, + { name: 'doh_remark', type: 'text' }, + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_tbl_document_operation_history_doh_id ON tbl_document_operation_history (doh_id)', + 'CREATE INDEX idx_tbl_document_operation_history_doh_document_id ON tbl_document_operation_history (doh_document_id)', + 'CREATE INDEX idx_tbl_document_operation_history_doh_user_id ON tbl_document_operation_history (doh_user_id)', + 'CREATE INDEX idx_tbl_document_operation_history_doh_operation_type ON tbl_document_operation_history (doh_operation_type)', + ], + }, +]; + +function normalizeFieldPayload(field, existingField) { + const payload = existingField + ? Object.assign({}, existingField) + : { + name: field.name, + type: field.type, + }; + + if (existingField && existingField.id) { + payload.id = existingField.id; + } + + payload.name = field.name; + payload.type = field.type; + + if (typeof field.required !== 'undefined') { + payload.required = field.required; + } + + if (field.type === 'file') { + payload.maxSelect = typeof field.maxSelect === 'number' ? field.maxSelect : 0; + payload.maxSize = typeof field.maxSize === 'number' ? field.maxSize : 0; + payload.mimeTypes = Array.isArray(field.mimeTypes) && field.mimeTypes.length ? field.mimeTypes : null; + } + + return payload; +} + +function buildCollectionPayload(collectionData, existingCollection) { + if (!existingCollection) { + return { + name: collectionData.name, + type: collectionData.type, + fields: collectionData.fields.map((field) => normalizeFieldPayload(field, null)), + indexes: collectionData.indexes, + }; + } + + const targetFieldMap = new Map(collectionData.fields.map((field) => [field.name, field])); + const fields = (existingCollection.fields || []).map((existingField) => { + const targetField = targetFieldMap.get(existingField.name); + if (!targetField) { + return existingField; + } + + targetFieldMap.delete(existingField.name); + return normalizeFieldPayload(targetField, existingField); + }); + + for (const field of targetFieldMap.values()) { + fields.push(normalizeFieldPayload(field, null)); + } + + return { + name: collectionData.name, + type: collectionData.type, + fields: fields, + indexes: collectionData.indexes, + }; +} + +function normalizeFieldList(fields) { + return (fields || []).map((field) => ({ + name: field.name, + type: field.type, + })); +} + +async function createOrUpdateCollection(collectionData) { + console.log(`🔄 正在处理表: ${collectionData.name} ...`); + + try { + const existing = await pb.collections.getOne(collectionData.name); + await pb.collections.update(existing.id, buildCollectionPayload(collectionData, existing)); + console.log(`♻️ ${collectionData.name} 已存在,已按最新结构更新。`); + } catch (error) { + if (error.status === 404) { + try { + await pb.collections.create(buildCollectionPayload(collectionData, null)); + console.log(`✅ ${collectionData.name} 创建完成。`); + return; + } catch (createError) { + console.error(`❌ 创建集合 ${collectionData.name} 失败:`, { + status: createError.status, + message: createError.message, + response: createError.response?.data, + }); + throw createError; + } + } + + console.error(`❌ 处理集合 ${collectionData.name} 失败:`, { + status: error.status, + message: error.message, + response: error.response?.data, + }); + throw error; + } +} + +async function verifyCollections(targetCollections) { + console.log('\n🔍 开始校验文档相关表结构与索引...'); + + for (const target of targetCollections) { + const remote = await pb.collections.getOne(target.name); + const remoteFields = normalizeFieldList(remote.fields); + const targetFields = normalizeFieldList(target.fields); + const remoteFieldMap = new Map(remoteFields.map((field) => [field.name, field.type])); + const missingFields = []; + const mismatchedTypes = []; + + for (const field of targetFields) { + if (!remoteFieldMap.has(field.name)) { + missingFields.push(field.name); + continue; + } + + if (remoteFieldMap.get(field.name) !== field.type) { + mismatchedTypes.push(`${field.name}:${remoteFieldMap.get(field.name)}!=${field.type}`); + } + } + + const remoteIndexes = new Set(remote.indexes || []); + const missingIndexes = target.indexes.filter((indexSql) => !remoteIndexes.has(indexSql)); + + if (remote.type !== target.type) { + throw new Error(`${target.name} 类型不匹配,期望 ${target.type},实际 ${remote.type}`); + } + + if (!missingFields.length && !mismatchedTypes.length && !missingIndexes.length) { + console.log(`✅ ${target.name} 校验通过。`); + continue; + } + + console.log(`❌ ${target.name} 校验失败:`); + if (missingFields.length) { + console.log(` - 缺失字段: ${missingFields.join(', ')}`); + } + if (mismatchedTypes.length) { + console.log(` - 字段类型不匹配: ${mismatchedTypes.join(', ')}`); + } + if (missingIndexes.length) { + console.log(` - 缺失索引: ${missingIndexes.join(' | ')}`); + } + + throw new Error(`${target.name} 结构与预期不一致`); + } +} + +async function init() { + try { + console.log(`🔄 正在连接 PocketBase: ${PB_URL}`); + pb.authStore.save(AUTH_TOKEN, null); + console.log('✅ 已使用 POCKETBASE_AUTH_TOKEN 载入认证状态。'); + + for (const collectionData of collections) { + await createOrUpdateCollection(collectionData); + } + + await verifyCollections(collections); + console.log('\n🎉 文档相关表结构初始化并校验完成!'); + } catch (error) { + console.error('❌ 初始化失败:', error.response?.data || error.message); + process.exitCode = 1; + } +} + +init();