diff --git a/docs/pb_document_tables.md b/docs/pb_document_tables.md index a82d52d..e534d08 100644 --- a/docs/pb_document_tables.md +++ b/docs/pb_document_tables.md @@ -4,10 +4,11 @@ 补充约定: -- `document_image`、`document_video` 只保存关联的 `attachments_id`,不直接存文件。 +- `document_image`、`document_video` 只保存关联的 `attachments_id`,不直接存文件;当存在多个附件时,统一使用 `|` 分隔,例如:`ATT-001|ATT-002|ATT-003`。 - `document_keywords`、`document_product_categories`、`document_application_scenarios`、`document_hotel_type` 统一使用竖线分隔字符串,例如:`分类A|分类B|分类C`。 - `document_owner` 的业务含义为“上传者openid”。 - `attachments_link` 是 PocketBase `file` 字段,保存附件本体;访问链接由 PocketBase 根据记录和文件名生成。 +- 文档字段中,面向用户填写的字段里只有 `document_title`、`document_type` 设为必填,其余字段均允许为空。 --- @@ -45,13 +46,13 @@ | document_id | text | 文档业务 id,唯一标识符 | | document_effect_date | date | 文档生效日期 | | document_expiry_date | date | 文档到期日期 | -| document_type | text | 文档类型 | -| document_title | text | 文档标题 | +| 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_image | text | 关联多个 `attachments_id`,使用 `|` 分隔 | +| document_video | text | 关联多个 `attachments_id`,使用 `|` 分隔 | | document_owner | text | 上传者openid | | document_relation_model | text | 关联机型/模型标识 | | document_keywords | text | 关键词,竖线分隔 | 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 513fa31..cb4f320 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 @@ -226,6 +226,35 @@ function validateDocumentDetailBody(e) { } } +function normalizeAttachmentIdList(value, fieldName) { + if (Array.isArray(value)) { + return value + .map(function (item) { + return String(item || '').trim() + }) + .filter(function (item) { + return !!item + }) + } + + if (typeof value === 'undefined' || value === null || value === '') { + return [] + } + + if (typeof value !== 'string') { + throw createAppError(400, fieldName + ' 类型错误,需为字符串或数组') + } + + return value + .split('|') + .map(function (item) { + return String(item || '').trim() + }) + .filter(function (item) { + return !!item + }) +} + function validateDocumentMutationBody(e, isUpdate) { const payload = parseBody(e) @@ -233,6 +262,14 @@ function validateDocumentMutationBody(e, isUpdate) { throw createAppError(400, 'document_id 为必填项') } + if (!payload.document_title) { + throw createAppError(400, 'document_title 为必填项') + } + + if (!payload.document_type) { + throw createAppError(400, 'document_type 为必填项') + } + return { document_id: payload.document_id || '', document_effect_date: payload.document_effect_date || '', @@ -242,13 +279,13 @@ function validateDocumentMutationBody(e, isUpdate) { 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_image: normalizeAttachmentIdList(payload.document_image, 'document_image'), + document_video: normalizeAttachmentIdList(payload.document_video, '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_share_count: typeof payload.document_share_count === 'undefined' ? '' : payload.document_share_count, + document_download_count: typeof payload.document_download_count === 'undefined' ? '' : payload.document_download_count, + document_favorite_count: typeof payload.document_favorite_count === 'undefined' ? '' : payload.document_favorite_count, document_status: payload.document_status || '', document_embedding_status: payload.document_embedding_status || '', document_embedding_error: payload.document_embedding_error || '', 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 index df2769f..3f0f334 100644 --- 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 @@ -52,6 +52,45 @@ function normalizeNumberValue(value, fieldName) { return num } +function normalizeOptionalNumberValue(value, fieldName) { + if (value === '' || value === null || typeof value === 'undefined') { + return null + } + + return normalizeNumberValue(value, fieldName) +} + +function parseAttachmentIdList(value) { + if (Array.isArray(value)) { + const result = [] + for (let i = 0; i < value.length; i += 1) { + const current = String(value[i] || '').replace(/^\s+|\s+$/g, '') + if (current && result.indexOf(current) === -1) { + result.push(current) + } + } + return result + } + + const text = String(value || '').replace(/^\s+|\s+$/g, '') + if (!text) return [] + + const parts = text.split('|') + const result = [] + for (let i = 0; i < parts.length; i += 1) { + const current = String(parts[i] || '').replace(/^\s+|\s+$/g, '') + if (current && result.indexOf(current) === -1) { + result.push(current) + } + } + + return result +} + +function serializeAttachmentIdList(value) { + return parseAttachmentIdList(value).join('|') +} + function exportAttachmentRecord(record) { const storedFilename = record.getString('attachments_link') @@ -84,13 +123,34 @@ function findAttachmentRecordByAttachmentId(attachmentId) { return records.length ? records[0] : null } +function resolveAttachmentList(value) { + const ids = parseAttachmentIdList(value) + const attachments = [] + const urls = [] + + for (let i = 0; i < ids.length; i += 1) { + const currentRecord = findAttachmentRecordByAttachmentId(ids[i]) + if (!currentRecord) { + continue + } + + const exported = exportAttachmentRecord(currentRecord) + attachments.push(exported) + urls.push(exported.attachments_url) + } + + return { + ids: ids, + attachments: attachments, + urls: urls, + } +} + 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 + const imageAttachmentList = resolveAttachmentList(record.getString('document_image')) + const videoAttachmentList = resolveAttachmentList(record.getString('document_video')) + const firstImageAttachment = imageAttachmentList.attachments.length ? imageAttachmentList.attachments[0] : null + const firstVideoAttachment = videoAttachmentList.attachments.length ? videoAttachmentList.attachments[0] : null return { pb_id: record.id, @@ -102,12 +162,18 @@ function exportDocumentRecord(record) { 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_image: imageAttachmentList.ids.join('|'), + document_image_ids: imageAttachmentList.ids, + document_image_urls: imageAttachmentList.urls, + document_image_attachments: imageAttachmentList.attachments, + document_image_url: firstImageAttachment ? firstImageAttachment.attachments_url : '', + document_image_attachment: firstImageAttachment, + document_video: videoAttachmentList.ids.join('|'), + document_video_ids: videoAttachmentList.ids, + document_video_urls: videoAttachmentList.urls, + document_video_attachments: videoAttachmentList.attachments, + document_video_url: firstVideoAttachment ? firstVideoAttachment.attachments_url : '', + document_video_attachment: firstVideoAttachment, document_owner: record.getString('document_owner'), document_relation_model: record.getString('document_relation_model'), document_keywords: record.getString('document_keywords'), @@ -164,12 +230,14 @@ function createHistoryRecord(txApp, payload) { txApp.save(record) } -function ensureAttachmentExists(attachmentId, fieldName) { - if (!attachmentId) return +function ensureAttachmentIdsExist(value, fieldName) { + const attachmentIds = parseAttachmentIdList(value) - const record = findAttachmentRecordByAttachmentId(attachmentId) - if (!record) { - throw createAppError(400, fieldName + ' 对应的附件不存在') + for (let i = 0; i < attachmentIds.length; i += 1) { + const record = findAttachmentRecordByAttachmentId(attachmentIds[i]) + if (!record) { + throw createAppError(400, fieldName + ' 中存在不存在的附件:' + attachmentIds[i]) + } } } @@ -245,10 +313,15 @@ function deleteAttachment(attachmentId) { 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, '附件已被文档引用,无法删除') + const documentRecords = $app.findRecordsByFilter('tbl_document', '', '', 500, 0) + for (let i = 0; i < documentRecords.length; i += 1) { + const current = documentRecords[i] + const imageIds = parseAttachmentIdList(current.getString('document_image')) + const videoIds = parseAttachmentIdList(current.getString('document_video')) + + if (imageIds.indexOf(attachmentId) !== -1 || videoIds.indexOf(attachmentId) !== -1) { + throw createAppError(400, '附件已被文档引用,无法删除') + } } try { @@ -309,8 +382,8 @@ function getDocumentDetail(documentId) { } function createDocument(userOpenid, payload) { - ensureAttachmentExists(payload.document_image, 'document_image') - ensureAttachmentExists(payload.document_video, 'document_video') + ensureAttachmentIdsExist(payload.document_image, 'document_image') + ensureAttachmentIdsExist(payload.document_video, 'document_video') const targetDocumentId = payload.document_id || buildBusinessId('DOC') const duplicated = findDocumentRecordByDocumentId(targetDocumentId) @@ -330,16 +403,16 @@ function createDocument(userOpenid, payload) { 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_image', serializeAttachmentIdList(payload.document_image)) + record.set('document_video', serializeAttachmentIdList(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_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count')) + record.set('document_download_count', normalizeOptionalNumberValue(payload.document_download_count, 'document_download_count')) + record.set('document_favorite_count', normalizeOptionalNumberValue(payload.document_favorite_count, 'document_favorite_count')) + record.set('document_status', payload.document_status || '') + record.set('document_embedding_status', payload.document_embedding_status || '') 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 || '') @@ -368,8 +441,8 @@ function updateDocument(userOpenid, payload) { throw createAppError(404, '未找到待修改的文档') } - ensureAttachmentExists(payload.document_image, 'document_image') - ensureAttachmentExists(payload.document_video, 'document_video') + ensureAttachmentIdsExist(payload.document_image, 'document_image') + ensureAttachmentIdsExist(payload.document_video, 'document_video') return $app.runInTransaction(function (txApp) { const target = txApp.findRecordById('tbl_document', record.id) @@ -381,13 +454,13 @@ function updateDocument(userOpenid, payload) { 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_image', serializeAttachmentIdList(payload.document_image)) + target.set('document_video', serializeAttachmentIdList(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_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count')) + target.set('document_download_count', normalizeOptionalNumberValue(payload.document_download_count, 'document_download_count')) + target.set('document_favorite_count', normalizeOptionalNumberValue(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 || '') diff --git a/pocket-base/bai_web_pb_hooks/pages/dictionary-manage.js b/pocket-base/bai_web_pb_hooks/pages/dictionary-manage.js index f7136bf..9992825 100644 --- a/pocket-base/bai_web_pb_hooks/pages/dictionary-manage.js +++ b/pocket-base/bai_web_pb_hooks/pages/dictionary-manage.js @@ -83,8 +83,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
- - + + @@ -93,10 +93,9 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) { - + - @@ -121,23 +120,19 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) { @@ -184,7 +179,6 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) { const editorModal = document.getElementById('editorModal') const modalTitle = document.getElementById('modalTitle') const dictNameInput = document.getElementById('dictNameInput') - const parentIdInput = document.getElementById('parentIdInput') const enabledInput = document.getElementById('enabledInput') const remarkInput = document.getElementById('remarkInput') const itemsBody = document.getElementById('itemsBody') @@ -256,10 +250,9 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) { const enabledClass = item.dict_word_is_enabled ? 'badge badge-on' : 'badge badge-off' const enabledText = item.dict_word_is_enabled ? '启用' : '禁用' return '' - + '' + + '' + '' + '' - + '' + '' + '' + '
dict_name字典名称 启用 备注parent_id 枚举项 创建时间 操作
' + enabledText + '
' + renderItemsPreview(item.items) + '' + escapeHtml(item.created) + '' @@ -278,7 +271,6 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) { state.editingOriginalName = record ? record.dict_name : '' modalTitle.textContent = mode === 'create' ? '新增字典' : '编辑枚举项' dictNameInput.value = record ? record.dict_name : '' - parentIdInput.value = record ? (record.dict_word_parent_id || '') : '' enabledInput.value = record && !record.dict_word_is_enabled ? 'false' : 'true' remarkInput.value = record ? (record.dict_word_remark || '') : '' state.items = record && Array.isArray(record.items) && record.items.length @@ -352,7 +344,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) { const payload = { dict_name: dictNameInput.value.trim(), original_dict_name: state.editingOriginalName, - dict_word_parent_id: parentIdInput.value.trim(), + dict_word_parent_id: '', dict_word_is_enabled: enabledInput.value === 'true', dict_word_remark: remarkInput.value.trim(), items: items, @@ -386,7 +378,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) { dict_name: (row.find(function (el) { return el.getAttribute('data-field') === 'dict_name' }) || {}).value || targetName, dict_word_is_enabled: ((row.find(function (el) { return el.getAttribute('data-field') === 'dict_word_is_enabled' }) || {}).value || 'true') === 'true', dict_word_remark: (row.find(function (el) { return el.getAttribute('data-field') === 'dict_word_remark' }) || {}).value || '', - dict_word_parent_id: (row.find(function (el) { return el.getAttribute('data-field') === 'dict_word_parent_id' }) || {}).value || '', + dict_word_parent_id: '', items: record.items || [], } diff --git a/pocket-base/bai_web_pb_hooks/pages/document-manage.js b/pocket-base/bai_web_pb_hooks/pages/document-manage.js index 3b2f416..04aea57 100644 --- a/pocket-base/bai_web_pb_hooks/pages/document-manage.js +++ b/pocket-base/bai_web_pb_hooks/pages/document-manage.js @@ -22,7 +22,7 @@ routerAdd('GET', '/manage/document-manage', function (e) { .panel + .panel { margin-top: 24px; } h1, h2 { margin-top: 0; } p { color: #4b5563; line-height: 1.7; } - .actions, .form-actions { display: flex; flex-wrap: wrap; gap: 12px; } + .actions, .form-actions, .toolbar, .table-actions, .file-actions { display: flex; flex-wrap: wrap; gap: 12px; } .btn { border: none; border-radius: 12px; padding: 10px 16px; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; } .btn-primary { background: #2563eb; color: #fff; } .btn-light { background: #f8fafc; color: #334155; border: 1px solid #dbe3f0; } @@ -43,9 +43,23 @@ routerAdd('GET', '/manage/document-manage', function (e) { tr:hover td { background: #f8fafc; } .empty { text-align: center; padding: 24px 16px; color: #64748b; } .muted { color: #64748b; font-size: 12px; } - .doc-links a { display: inline-block; margin-right: 10px; color: #2563eb; text-decoration: none; font-weight: 600; } + .doc-links a, .file-card a { display: inline-block; margin-right: 10px; color: #2563eb; text-decoration: none; font-weight: 600; } + .btn-warning { background: #f59e0b; color: #fff; } + .editor-banner { display: inline-flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 999px; background: #eff6ff; color: #1d4ed8; font-size: 13px; font-weight: 700; } + .file-group { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 16px; } + .file-box { border: 1px solid #dbe3f0; border-radius: 18px; padding: 16px; background: #f8fbff; } + .dropzone { margin-top: 8px; border: 2px dashed #bfdbfe; border-radius: 16px; background: #ffffff; padding: 16px; transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; cursor: pointer; } + .dropzone.dragover { border-color: #2563eb; background: #eff6ff; box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.08); } + .dropzone-title { font-weight: 700; color: #1e3a8a; } + .dropzone-text { color: #475569; font-size: 13px; margin-top: 6px; } + .dropzone input[type="file"] { margin-top: 12px; } + .file-list { display: grid; gap: 10px; margin-top: 12px; } + .file-card { border: 1px solid #dbe3f0; border-radius: 14px; padding: 12px; background: #fff; } + .file-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + .file-card-title { font-weight: 700; word-break: break-all; } + .file-meta { color: #64748b; font-size: 12px; margin-top: 6px; word-break: break-all; } @media (max-width: 960px) { - .grid { grid-template-columns: 1fr; } + .grid, .file-group { grid-template-columns: 1fr; } table, thead, tbody, th, td, tr { display: block; } thead { display: none; } tr { margin-bottom: 14px; border: 1px solid #e5e7eb; border-radius: 16px; overflow: hidden; } @@ -58,19 +72,26 @@ routerAdd('GET', '/manage/document-manage', function (e) {

文档管理

-

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

+

页面会通过 /pb/api/* 调用 PocketBase hooks。图片和视频都支持多选上传;编辑已有文档时,会先回显当前已绑定的多图片、多视频,并支持从文档中移除或继续追加附件。

返回主页 登录页 +
-

新增文档

-
+
+
+

新增文档

+
只有文档标题和文档类型为必填,其余字段允许为空。
+
+
当前模式:新建
+
+
@@ -81,11 +102,11 @@ routerAdd('GET', '/manage/document-manage', function (e) {
- +
- +
@@ -136,17 +157,34 @@ routerAdd('GET', '/manage/document-manage', function (e) {
-
- - +
+
+
+

图片附件

+ +
+
拖拽图片到这里,或点击选择文件
+
支持从 Windows 文件夹直接拖入;选择后先进入待上传区,保存文档时才会真正上传。
+ +
+
+
-
- - +
+

视频附件

+ +
+
拖拽视频到这里,或点击选择文件
+
支持从 Windows 文件夹直接拖入;选择后先进入待上传区,保存文档时才会真正上传。
+ +
+
+
+
@@ -175,8 +213,17 @@ routerAdd('GET', '/manage/document-manage', function (e) { 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 7d252bc..2a539e2 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 @@ -81,7 +81,7 @@ ### 1. 登录路由显式错误响应 -`POST /api/wechat/login` 新增局部 try/catch: +`POST /pb/api/wechat/login` 新增局部 try/catch: - 保留业务状态码 - 返回 `{ code, msg, data }` @@ -132,39 +132,39 @@ 当前 active PocketBase hooks 契约如下: -- `POST /api/system/test-helloworld` -- `POST /api/system/health` -- `POST /api/system/refresh-token` -- `POST /api/platform/register` -- `POST /api/platform/login` -- `POST /api/wechat/login` -- `POST /api/wechat/profile` -- `POST /api/dictionary/list` -- `POST /api/dictionary/detail` -- `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` +- `POST /pb/api/system/test-helloworld` +- `POST /pb/api/system/health` +- `POST /pb/api/system/refresh-token` +- `POST /pb/api/platform/register` +- `POST /pb/api/platform/login` +- `POST /pb/api/wechat/login` +- `POST /pb/api/wechat/profile` +- `POST /pb/api/dictionary/list` +- `POST /pb/api/dictionary/detail` +- `POST /pb/api/dictionary/create` +- `POST /pb/api/dictionary/update` +- `POST /pb/api/dictionary/delete` +- `POST /pb/api/attachment/list` +- `POST /pb/api/attachment/detail` +- `POST /pb/api/attachment/upload` +- `POST /pb/api/attachment/delete` +- `POST /pb/api/document/list` +- `POST /pb/api/document/detail` +- `POST /pb/api/document/create` +- `POST /pb/api/document/update` +- `POST /pb/api/document/delete` +- `POST /pb/api/document-history/list` 其中平台用户链路补充为: -### `POST /api/platform/register` +### `POST /pb/api/platform/register` - body 必填:`users_name`、`users_phone`、`password`、`passwordConfirm`、`users_picture` - 自动生成 GUID 并写入统一身份字段 `openid` - 写入 `users_idtype = ManagePlatform` - 成功时返回统一结构:`code`、`msg`、`data`,并在顶层额外返回 `token` -### `POST /api/platform/login` +### `POST /pb/api/platform/login` - body 必填:`login_account`、`password` - 仅允许 `users_idtype = ManagePlatform` @@ -174,7 +174,7 @@ 其中: -### `POST /api/wechat/login` +### `POST /pb/api/wechat/login` - body 必填:`users_wx_code` - 自动以微信 code 换取微信侧 `openid` 并写入统一身份字段 @@ -182,13 +182,13 @@ - 写入 `users_idtype = WeChat` - 成功时返回统一结构:`code`、`msg`、`data`,并在顶层额外返回 `token` -### `POST /api/wechat/profile` +### `POST /pb/api/wechat/profile` - 需 `Authorization` - 基于当前 auth record 的 `openid` 定位用户 - 服务端用 `users_phone_code` 换取手机号后保存 -### `POST /api/system/refresh-token` +### `POST /pb/api/system/refresh-token` - body 可选:`users_wx_code`(允许为空) - `Authorization` 可选: @@ -202,16 +202,16 @@ 新增 `dictionary` 分类接口,统一要求平台管理用户访问: -- `POST /api/dictionary/list` +- `POST /pb/api/dictionary/list` - 支持按 `dict_name` 模糊搜索 - 返回字典全量信息,并将 `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 组装为 `items` -- `POST /api/dictionary/detail` +- `POST /pb/api/dictionary/detail` - 按 `dict_name` 查询单条字典 -- `POST /api/dictionary/create` +- `POST /pb/api/dictionary/create` - 新增字典,`system_dict_id` 自动生成,`dict_name` 唯一 -- `POST /api/dictionary/update` +- `POST /pb/api/dictionary/update` - 按 `original_dict_name` / `dict_name` 更新字典 -- `POST /api/dictionary/delete` +- `POST /pb/api/dictionary/delete` - 按 `dict_name` 真删除字典 说明: @@ -223,21 +223,21 @@ 新增 `attachment` 分类接口,统一要求平台管理用户访问: -- `POST /api/attachment/list` +- `POST /pb/api/attachment/list` - 支持按 `attachments_id`、`attachments_filename` 模糊搜索 - 支持按 `attachments_status` 过滤 - 返回附件元数据以及 PocketBase 文件流链接 `attachments_url` -- `POST /api/attachment/detail` +- `POST /pb/api/attachment/detail` - 按 `attachments_id` 查询单个附件 - 返回文件流链接与下载链接 -- `POST /api/attachment/upload` +- `POST /pb/api/attachment/upload` - 使用 `multipart/form-data` - 文件字段固定为 `attachments_link` - 上传成功后自动生成 `attachments_id` - 自动写入 `attachments_owner = 当前用户 openid` -- `POST /api/attachment/delete` +- `POST /pb/api/attachment/delete` - 按 `attachments_id` 真删除附件 - - 若该附件已被 `tbl_document.document_image` 或 `document_video` 引用,则拒绝删除 + - 若该附件已被 `tbl_document.document_image` 或 `document_video` 中的任一附件列表引用,则拒绝删除 说明: @@ -250,42 +250,44 @@ 新增 `document` 分类接口,统一要求平台管理用户访问: -- `POST /api/document/list` +- `POST /pb/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_image_urls` + - `document_video_urls` + - `document_image_attachments` + - `document_video_attachments` +- `POST /pb/api/document/detail` - 按 `document_id` 查询单条文档 - - 返回与附件表联动解析后的文件流链接 -- `POST /api/document/create` + - 返回与附件表联动解析后的多文件流链接 +- `POST /pb/api/document/create` - 新增文档 - `document_id` 可不传,由服务端自动生成 - - `document_image`、`document_video` 必须传入已存在的 `attachments_id` + - `document_title`、`document_type` 为必填;其余字段均允许为空 + - `document_image`、`document_video` 支持传入多个已存在的 `attachments_id` - 成功后会写入一条文档操作历史,类型为 `create` -- `POST /api/document/update` +- `POST /pb/api/document/update` - 按 `document_id` 更新文档 - - 若传入附件字段,则会校验对应 `attachments_id` 是否存在 + - `document_title`、`document_type` 为必填;其余字段均允许为空 + - 若传入附件字段,则会校验多个 `attachments_id` 是否都存在 - 成功后会写入一条文档操作历史,类型为 `update` -- `POST /api/document/delete` +- `POST /pb/api/document/delete` - 按 `document_id` 真删除文档 - 删除前会写入一条文档操作历史,类型为 `delete` 说明: -- `document_image`、`document_video` 当前保存的是 `attachments_id`,不是 PocketBase 文件字段。 -- 文档查询时通过 `attachments_id -> tbl_attachments` 反查实际文件,并返回可直接访问的数据流链接。 +- `document_image`、`document_video` 当前保存的是多个 `attachments_id`,底层以 `|` 分隔文本持久化,不是 PocketBase 文件字段。 +- 文档查询时通过 `attachments_id -> tbl_attachments` 反查实际文件,并返回可直接访问的数据流链接数组。 - `document_owner` 语义为“上传者 openid”。 ### 文档操作历史接口 新增 `document-history` 分类接口,统一要求平台管理用户访问: -- `POST /api/document-history/list` +- `POST /pb/api/document-history/list` - 不传 `document_id` 时返回全部文档历史 - 传入 `document_id` 时仅返回该文档历史 - 结果按创建时间倒序排列 @@ -327,8 +329,11 @@ - 返回主页 - 文档管理页支持: - 先上传附件到 `tbl_attachments` - - 再把返回的 `attachments_id` 写入 `tbl_document.document_image` / `document_video` + - 再把返回的多个 `attachments_id` 写入 `tbl_document.document_image` / `document_video` + - 图片和视频都支持多选上传 - 新增文档 + - 编辑已有文档并回显多图片、多视频 + - 从文档中移除附件并在保存后删除对应附件记录 - 查询文档列表 - 直接展示 PocketBase 文件流链接 - 删除文档 @@ -337,14 +342,16 @@ - 原页面 `page-b.js` 已替换为 `document-manage.js` - 页面实际走的接口链路为: - - `/api/attachment/upload` - - `/api/document/create` - - `/api/document/list` - - `/api/document/delete` + - `/pb/api/attachment/upload` + - `/pb/api/document/create` + - `/pb/api/document/update` + - `/pb/api/attachment/delete` + - `/pb/api/document/list` + - `/pb/api/document/delete` ### 2. 健康检查版本探针 -`POST /api/system/health` 新增: +`POST /pb/api/system/health` 新增: - `data.version` @@ -419,9 +426,9 @@ OpenAPI 文档中已取消 `bearerAuth` 鉴权组件与接口级重复 `Authoriz 建议归档后的发布核验顺序: -1. `POST /api/system/health`:确认 `data.version` -2. `POST /api/platform/login`:确认返回统一结构与顶层 `token` -3. `POST /api/dictionary/list`:确认鉴权与字典接口可用 +1. `POST /pb/api/system/health`:确认 `data.version` +2. `POST /pb/api/platform/login`:确认返回统一结构与顶层 `token` +3. `POST /pb/api/dictionary/list`:确认鉴权与字典接口可用 --- @@ -429,3 +436,5 @@ OpenAPI 文档中已取消 `bearerAuth` 鉴权组件与接口级重复 `Authoriz - 已将本轮字典管理、PocketBase 页面、统一响应、平台登录兼容修复、健康检查版本探针、OpenAPI/Apifox 鉴权策略调整并入本变更记录。 - 本记录可作为当前 PocketBase hooks 阶段性归档基线继续维护。 + + diff --git a/pocket-base/spec/openapi.yaml b/pocket-base/spec/openapi.yaml index e133986..b281859 100644 --- a/pocket-base/spec/openapi.yaml +++ b/pocket-base/spec/openapi.yaml @@ -467,20 +467,48 @@ components: type: string document_image: type: string - description: 关联的 `attachments_id` + description: 关联多个 `attachments_id`,底层使用 `|` 分隔保存 + document_image_ids: + type: array + description: `document_image` 解析后的附件 id 列表 + items: + type: string + document_image_urls: + type: array + description: 根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接列表 + items: + type: string document_image_url: type: string - description: 根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接 + description: 兼容字段,返回第一张图片的文件流链接 + document_image_attachments: + type: array + items: + $ref: '#/components/schemas/AttachmentRecord' document_image_attachment: allOf: - $ref: '#/components/schemas/AttachmentRecord' nullable: true document_video: type: string - description: 关联的 `attachments_id` + description: 关联多个 `attachments_id`,底层使用 `|` 分隔保存 + document_video_ids: + type: array + description: `document_video` 解析后的附件 id 列表 + items: + type: string + document_video_urls: + type: array + description: 根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接列表 + items: + type: string document_video_url: type: string - description: 根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接 + description: 兼容字段,返回第一个视频的文件流链接 + document_video_attachments: + type: array + items: + $ref: '#/components/schemas/AttachmentRecord' document_video_attachment: allOf: - $ref: '#/components/schemas/AttachmentRecord' @@ -546,6 +574,7 @@ components: example: DOC-1743037200000-abc123 DocumentMutationRequest: type: object + required: [document_title, document_type] properties: document_id: type: string @@ -570,11 +599,21 @@ components: document_content: type: string document_image: - type: string - description: 图片附件的 `attachments_id` + oneOf: + - type: string + description: 多个图片附件 id 使用 `|` 分隔 + - type: array + items: + type: string + description: 图片附件 id 列表;支持数组或 `|` 分隔字符串 document_video: - type: string - description: 视频附件的 `attachments_id` + oneOf: + - type: string + description: 多个视频附件 id 使用 `|` 分隔 + - type: array + items: + type: string + description: 视频附件 id 列表;支持数组或 `|` 分隔字符串 document_relation_model: type: string document_keywords: @@ -1222,8 +1261,8 @@ paths: description: | 仅允许 `ManagePlatform` 用户访问。 支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。 - 返回结果会自动根据 `document_image`、`document_video` 关联 `tbl_attachments`, - 额外补充 `document_image_url`、`document_video_url` 以及对应附件对象。 + 返回结果会自动根据 `document_image`、`document_video` 中的多个 `attachments_id` 关联 `tbl_attachments`, + 额外补充 `document_image_urls`、`document_video_urls` 以及对应附件对象数组。 requestBody: required: false content: @@ -1259,7 +1298,7 @@ paths: summary: 查询文档详情 description: | 仅允许 `ManagePlatform` 用户访问。 - 按 `document_id` 查询单条文档,并返回与附件表联动解析后的文件流链接。 + 按 `document_id` 查询单条文档,并返回与附件表联动解析后的多文件流链接。 requestBody: required: true content: @@ -1295,7 +1334,8 @@ paths: description: | 仅允许 `ManagePlatform` 用户访问。 `document_id` 可选;未传时服务端自动生成。 - `document_image`、`document_video` 需传入已存在于 `tbl_attachments` 的 `attachments_id`。 + `document_title`、`document_type` 为必填;其余字段均允许为空。 + `document_image`、`document_video` 支持传入多个已存在于 `tbl_attachments` 的 `attachments_id`。 成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `create`。 requestBody: required: true @@ -1331,7 +1371,9 @@ paths: summary: 修改文档 description: | 仅允许 `ManagePlatform` 用户访问。 - 按 `document_id` 定位现有文档并更新;若传入 `document_image`、`document_video`,则必须是已存在的 `attachments_id`。 + 按 `document_id` 定位现有文档并更新。 + `document_title`、`document_type` 为必填;其余字段均允许为空。 + 若传入 `document_image`、`document_video`,则支持多个 `attachments_id`,并会逐一校验是否存在。 成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。 requestBody: required: true diff --git a/script/database_schema.md b/script/database_schema.md index 70c7dde..d21442b 100644 --- a/script/database_schema.md +++ b/script/database_schema.md @@ -20,6 +20,7 @@ **索引规划 (Indexes):** * `CREATE UNIQUE INDEX` 针对 `system_dict_id` (确保业务主键唯一) +* `CREATE UNIQUE INDEX` 针对 `dict_name` (确保词典名称全局唯一,并支持按名称唯一索引查询) * `CREATE INDEX` 针对 `dict_word_parent_id` (加速父子级联查询) --- diff --git a/script/pocketbase.documents.js b/script/pocketbase.documents.js index bf38589..0bfe684 100644 --- a/script/pocketbase.documents.js +++ b/script/pocketbase.documents.js @@ -49,8 +49,8 @@ const collections = [ { 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_type', type: 'text', required: true }, + { name: 'document_title', type: 'text', required: true }, { name: 'document_subtitle', type: 'text' }, { name: 'document_summary', type: 'text' }, { name: 'document_content', type: 'text' }, @@ -167,6 +167,7 @@ function normalizeFieldList(fields) { return (fields || []).map((field) => ({ name: field.name, type: field.type, + required: !!field.required, })); } @@ -210,8 +211,10 @@ async function verifyCollections(targetCollections) { const remoteFields = normalizeFieldList(remote.fields); const targetFields = normalizeFieldList(target.fields); const remoteFieldMap = new Map(remoteFields.map((field) => [field.name, field.type])); + const remoteRequiredMap = new Map(remoteFields.map((field) => [field.name, field.required])); const missingFields = []; const mismatchedTypes = []; + const mismatchedRequired = []; for (const field of targetFields) { if (!remoteFieldMap.has(field.name)) { @@ -222,6 +225,10 @@ async function verifyCollections(targetCollections) { if (remoteFieldMap.get(field.name) !== field.type) { mismatchedTypes.push(`${field.name}:${remoteFieldMap.get(field.name)}!=${field.type}`); } + + if (remoteRequiredMap.get(field.name) !== !!field.required) { + mismatchedRequired.push(`${field.name}:${remoteRequiredMap.get(field.name)}!=${!!field.required}`); + } } const remoteIndexes = new Set(remote.indexes || []); @@ -231,7 +238,7 @@ async function verifyCollections(targetCollections) { throw new Error(`${target.name} 类型不匹配,期望 ${target.type},实际 ${remote.type}`); } - if (!missingFields.length && !mismatchedTypes.length && !missingIndexes.length) { + if (!missingFields.length && !mismatchedTypes.length && !mismatchedRequired.length && !missingIndexes.length) { console.log(`✅ ${target.name} 校验通过。`); continue; } @@ -243,6 +250,9 @@ async function verifyCollections(targetCollections) { if (mismatchedTypes.length) { console.log(` - 字段类型不匹配: ${mismatchedTypes.join(', ')}`); } + if (mismatchedRequired.length) { + console.log(` - 字段必填属性不匹配: ${mismatchedRequired.join(', ')}`); + } if (missingIndexes.length) { console.log(` - 缺失索引: ${missingIndexes.join(' | ')}`); }