diff --git a/docs/deployment.md b/docs/deployment.md index a07b1d4..a945d5b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -320,6 +320,21 @@ VUE_APP_VERSION=1.0.0 4. 前端生产环境接口地址统一使用:`https://bai-api.blv-oa.com/api` 5. 后端对外公开地址统一使用 `APP_BASE_URL=https://bai-api.blv-oa.com` +### 大文件上传反向代理配置 + +如果 PocketBase 管理页或 hooks 页面需要上传较大的图片/视频附件,Nginx 需要同步放开请求体大小限制,否则浏览器会直接收到 `413 Content Too Large`,前端通常会看到 HTML 错页而不是 JSON。 + +建议在站点或对应 `location` 中增加: + +```nginx +client_max_body_size 200m; +proxy_request_buffering off; +proxy_read_timeout 600s; +proxy_send_timeout 600s; +``` + +如果当前 `tbl_attachments.attachments_link` 已放宽到约 4GB,反向代理层仍然需要按你的目标上传体积同步放开,否则即使 PocketBase 集合字段允许更大文件,上传仍会在进入 PocketBase 之前被拦截。 + ## 数据库配置 ### Pocketbase设置 @@ -450,4 +465,4 @@ docker logs -f 容器名称 ## 总结 -本部署方案采用Docker容器化部署,实现了前后端分离的架构,支持一键部署和自动化管理。通过宝塔面板的反向代理功能,实现了域名访问和SSL证书配置,为系统提供了安全、稳定的运行环境。 \ No newline at end of file +本部署方案采用Docker容器化部署,实现了前后端分离的架构,支持一键部署和自动化管理。通过宝塔面板的反向代理功能,实现了域名访问和SSL证书配置,为系统提供了安全、稳定的运行环境。 diff --git a/docs/pb_document_tables.md b/docs/pb_document_tables.md index e534d08..1fabc84 100644 --- a/docs/pb_document_tables.md +++ b/docs/pb_document_tables.md @@ -5,7 +5,9 @@ 补充约定: - `document_image`、`document_video` 只保存关联的 `attachments_id`,不直接存文件;当存在多个附件时,统一使用 `|` 分隔,例如:`ATT-001|ATT-002|ATT-003`。 +- `document_type` 使用多选字符串持久化,但格式特殊:`system_dict_id@dict_word_enum|system_dict_id@dict_word_enum`;前端显示时用枚举值描述,存库时保留该组合值。 - `document_keywords`、`document_product_categories`、`document_application_scenarios`、`document_hotel_type` 统一使用竖线分隔字符串,例如:`分类A|分类B|分类C`。 +- `document_status` 仅允许 `有效`、`过期` 两种值,并由系统根据生效日期与到期日期自动计算;当两者都为空时默认 `有效`。 - `document_owner` 的业务含义为“上传者openid”。 - `attachments_link` 是 PocketBase `file` 字段,保存附件本体;访问链接由 PocketBase 根据记录和文件名生成。 - 文档字段中,面向用户填写的字段里只有 `document_title`、`document_type` 设为必填,其余字段均允许为空。 @@ -19,7 +21,7 @@ | 字段名 | 类型 | 备注 | | :--- | :--- | :--- | | attachments_id | text | 附件业务 id,唯一标识符 | -| attachments_link | file | 附件本体,单文件,不限制文件类型,单文件上限 100MB | +| attachments_link | file | 附件本体,单文件,不限制文件类型,数据库字段上限已放宽到约 4GB | | attachments_filename | text | 原始文件名 | | attachments_filetype | text | 文件类型/MIME | | attachments_size | number | 附件大小 | @@ -46,7 +48,7 @@ | document_id | text | 文档业务 id,唯一标识符 | | document_effect_date | date | 文档生效日期 | | document_expiry_date | date | 文档到期日期 | -| document_type | text | 文档类型,必填 | +| document_type | text | 文档类型,必填;多选时按 `system_dict_id@dict_word_enum|...` 保存 | | document_title | text | 文档标题,必填 | | document_subtitle | text | 文档副标题 | | document_summary | text | 文档摘要 | @@ -55,18 +57,18 @@ | document_video | text | 关联多个 `attachments_id`,使用 `|` 分隔 | | document_owner | text | 上传者openid | | document_relation_model | text | 关联机型/模型标识 | -| document_keywords | text | 关键词,竖线分隔 | +| document_keywords | text | 关键词,多选后用 `|` 分隔保存 | | document_share_count | number | 分享次数 | | document_download_count | number | 下载次数 | | document_favorite_count | number | 收藏次数 | -| document_status | text | 文档状态 | +| 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_product_categories | text | 产品关联文档,多选后从 `文档-产品关联文档` 字典保存为 `|` 分隔字符串 | +| document_application_scenarios | text | 筛选依据,多选后从 `文档-筛选依据` 字典保存为 `|` 分隔字符串 | +| document_hotel_type | text | 适用场景,多选后从 `文档-适用场景` 字典保存为 `|` 分隔字符串 | | document_remark | 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 cb4f320..938560c 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 @@ -127,6 +127,7 @@ function normalizeDictionaryItem(item, index) { enum: String(current.enum), description: String(current.description), sortOrder: sortOrderNumber, + image: current.image ? String(current.image) : '', } } diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js index 8e8c0cb..73a0e48 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js @@ -1,5 +1,6 @@ const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`) const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) +const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`) function buildSystemDictId() { return 'DICT-' + new Date().getTime() + '-' + $security.randomString(6) @@ -18,18 +19,31 @@ function safeJsonParse(text, fallback) { function normalizeItemsFromRecord(record) { const enums = safeJsonParse(record.getString('dict_word_enum'), []) const descriptions = safeJsonParse(record.getString('dict_word_description'), []) + const images = safeJsonParse(record.getString('dict_word_image'), []) const sortOrders = safeJsonParse(record.getString('dict_word_sort_order'), []) - const maxLength = Math.max(enums.length, descriptions.length, sortOrders.length) + const maxLength = Math.max(enums.length, descriptions.length, images.length, sortOrders.length) const items = [] for (let i = 0; i < maxLength; i += 1) { - if (typeof enums[i] === 'undefined' && typeof descriptions[i] === 'undefined' && typeof sortOrders[i] === 'undefined') { + if (typeof enums[i] === 'undefined' && typeof descriptions[i] === 'undefined' && typeof images[i] === 'undefined' && typeof sortOrders[i] === 'undefined') { continue } + const imageAttachmentId = typeof images[i] === 'undefined' ? '' : String(images[i] || '') + let imageAttachment = null + if (imageAttachmentId) { + try { + imageAttachment = documentService.getAttachmentDetail(imageAttachmentId) + } catch (_error) { + imageAttachment = null + } + } items.push({ enum: typeof enums[i] === 'undefined' ? '' : String(enums[i]), description: typeof descriptions[i] === 'undefined' ? '' : String(descriptions[i]), + image: imageAttachmentId, + imageUrl: imageAttachment ? imageAttachment.attachments_url : '', + imageAttachment: imageAttachment, sortOrder: Number(sortOrders[i] || 0), }) } @@ -69,16 +83,19 @@ function ensureDictionaryNameUnique(dictName, excludeId) { function fillDictionaryItems(record, items) { const enums = [] const descriptions = [] + const images = [] const sortOrders = [] for (let i = 0; i < items.length; i += 1) { enums.push(items[i].enum) descriptions.push(items[i].description) + images.push(items[i].image || '') sortOrders.push(items[i].sortOrder) } record.set('dict_word_enum', JSON.stringify(enums)) record.set('dict_word_description', JSON.stringify(descriptions)) + record.set('dict_word_image', JSON.stringify(images)) record.set('dict_word_sort_order', JSON.stringify(sortOrders)) } @@ -197,4 +214,4 @@ module.exports = { createDictionary, updateDictionary, deleteDictionary, -} \ No newline at end of file +} 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 3f0f334..00bb69a 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 @@ -39,6 +39,54 @@ function normalizeDateValue(value) { throw createAppError(400, '日期字段格式错误') } +function extractDateOnly(value) { + const text = String(value || '').replace(/^\s+|\s+$/g, '') + const match = text.match(/^\d{4}-\d{2}-\d{2}/) + return match ? match[0] : '' +} + +function getTodayDateString() { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return year + '-' + month + '-' + day +} + +function calculateDocumentStatus(effectDate, expiryDate, fallbackStatus) { + const start = extractDateOnly(effectDate) + const end = extractDateOnly(expiryDate) + const today = getTodayDateString() + + if (!start && !end) { + return fallbackStatus === '过期' ? '过期' : '有效' + } + if (start && today < start) { + return '过期' + } + if (end && today > end) { + return '过期' + } + return '有效' +} + +function ensureDocumentStatus(record) { + const nextStatus = calculateDocumentStatus( + record.get('document_effect_date'), + record.get('document_expiry_date'), + record.getString('document_status') + ) + + if (record.getString('document_status') !== nextStatus) { + record.set('document_status', nextStatus) + try { + $app.save(record) + } catch (_err) {} + } + + return nextStatus +} + function normalizeNumberValue(value, fieldName) { if (value === '' || value === null || typeof value === 'undefined') { return 0 @@ -147,6 +195,7 @@ function resolveAttachmentList(value) { } function exportDocumentRecord(record) { + const documentStatus = ensureDocumentStatus(record) const imageAttachmentList = resolveAttachmentList(record.getString('document_image')) const videoAttachmentList = resolveAttachmentList(record.getString('document_video')) const firstImageAttachment = imageAttachmentList.attachments.length ? imageAttachmentList.attachments[0] : null @@ -180,7 +229,7 @@ function exportDocumentRecord(record) { 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_status: documentStatus, 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') || ''), @@ -394,10 +443,13 @@ function createDocument(userOpenid, payload) { return $app.runInTransaction(function (txApp) { const collection = txApp.findCollectionByNameOrId('tbl_document') const record = new Record(collection) + const effectDateValue = normalizeDateValue(payload.document_effect_date) + const expiryDateValue = normalizeDateValue(payload.document_expiry_date) + const documentStatus = calculateDocumentStatus(effectDateValue, expiryDateValue, payload.document_status) 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_effect_date', effectDateValue) + record.set('document_expiry_date', expiryDateValue) record.set('document_type', payload.document_type || '') record.set('document_title', payload.document_title || '') record.set('document_subtitle', payload.document_subtitle || '') @@ -411,7 +463,7 @@ function createDocument(userOpenid, payload) { 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_status', documentStatus) 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)) @@ -446,9 +498,12 @@ function updateDocument(userOpenid, payload) { return $app.runInTransaction(function (txApp) { const target = txApp.findRecordById('tbl_document', record.id) + const effectDateValue = normalizeDateValue(payload.document_effect_date) + const expiryDateValue = normalizeDateValue(payload.document_expiry_date) + const documentStatus = calculateDocumentStatus(effectDateValue, expiryDateValue, payload.document_status) - target.set('document_effect_date', normalizeDateValue(payload.document_effect_date)) - target.set('document_expiry_date', normalizeDateValue(payload.document_expiry_date)) + target.set('document_effect_date', effectDateValue) + target.set('document_expiry_date', expiryDateValue) target.set('document_type', payload.document_type || '') target.set('document_title', payload.document_title || '') target.set('document_subtitle', payload.document_subtitle || '') @@ -461,7 +516,7 @@ function updateDocument(userOpenid, payload) { 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_status', documentStatus) 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)) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/services/userService.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/userService.js index bea8bc1..44137f9 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_shared/services/userService.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/userService.js @@ -19,6 +19,10 @@ function buildUserId() { return 'U' + date + suffix } +function buildUserConversId() { + return 'UC-' + new Date().getTime() + '-' + $security.randomString(6) +} + function buildGuid() { return $security.randomString(8) + '-' + $security.randomString(4) + '-' + $security.randomString(4) + '-' + $security.randomString(4) + '-' + $security.randomString(12) } @@ -186,7 +190,17 @@ function ensureUserId(record) { } } +function ensureUsersConversId(record) { + if (!record.getString('users_convers_id')) { + record.set('users_convers_id', buildUserConversId()) + } +} + function saveAuthUserRecord(record) { + ensureUserId(record) + ensureUsersConversId(record) + ensureAuthIdentity(record) + try { $app.save(record) } catch (err) { @@ -227,11 +241,9 @@ function authenticateWechatUser(payload) { const collection = $app.findCollectionByNameOrId('tbl_auth_users') const record = new Record(collection) record.set('openid', openid) - ensureUserId(record) record.set('users_idtype', WECHAT_ID_TYPE) record.set('users_type', GUEST_USER_TYPE) record.set('users_auth_type', 0) - ensureAuthIdentity(record) saveAuthUserRecord(record) const user = enrichUser(record) @@ -278,7 +290,6 @@ function registerPlatformUser(payload) { const record = new Record(collection) record.set('openid', platformOpenid) - ensureUserId(record) record.set('users_idtype', MANAGE_PLATFORM_ID_TYPE) record.set('users_name', payload.users_name) record.set('users_phone', payload.users_phone) 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 9992825..b452664 100644 --- a/pocket-base/bai_web_pb_hooks/pages/dictionary-manage.js +++ b/pocket-base/bai_web_pb_hooks/pages/dictionary-manage.js @@ -17,18 +17,18 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) { diff --git a/pocket-base/bai_web_pb_hooks/pages/login.js b/pocket-base/bai_web_pb_hooks/pages/login.js index 5625338..683b54a 100644 --- a/pocket-base/bai_web_pb_hooks/pages/login.js +++ b/pocket-base/bai_web_pb_hooks/pages/login.js @@ -22,15 +22,17 @@ function renderLoginPage(e) { color: #1f2937; } .wrap { - max-width: 420px; + max-width: 1440px; margin: 0 auto; - padding: 72px 20px; + padding: 34px 14px; } .card { + max-width: 420px; + margin: 0 auto; background: #fff; border: 1px solid #dbe3f0; border-radius: 18px; - padding: 28px; + padding: 22px; box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08); } h1 { margin-top: 0; } 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 2a539e2..d8e8489 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 @@ -204,7 +204,7 @@ - `POST /pb/api/dictionary/list` - 支持按 `dict_name` 模糊搜索 - - 返回字典全量信息,并将 `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 组装为 `items` + - 返回字典全量信息,并将 `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order` 组装为 `items` - `POST /pb/api/dictionary/detail` - 按 `dict_name` 查询单条字典 - `POST /pb/api/dictionary/create` @@ -216,8 +216,9 @@ 说明: -- `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 已统一改为 JSON 字符串持久化,其中 `dict_word_sort_order` 已改为 `text` 类型。 -- 查询时统一聚合为:`items: [{ enum, description, sortOrder }]` +- `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order` 已统一改为 JSON 字符串持久化,其中 `dict_word_sort_order` 已改为 `text` 类型。 +- 字典项图片需先调用 `/pb/api/attachment/upload` 上传,再把返回的 `attachments_id` 写入 `dict_word_image` 对应位置。 +- 查询时统一聚合为:`items: [{ enum, description, image, imageUrl, imageAttachment, sortOrder }]` ### 附件管理接口 @@ -267,11 +268,16 @@ - `document_id` 可不传,由服务端自动生成 - `document_title`、`document_type` 为必填;其余字段均允许为空 - `document_image`、`document_video` 支持传入多个已存在的 `attachments_id` + - `document_type` 前端从单个字典来源中多选枚举值,最终按 `system_dict_id@dict_word_enum|...` 保存 + - `document_keywords`、`document_product_categories`、`document_application_scenarios`、`document_hotel_type` 统一从固定字典多选并按 `|` 保存 + - 其中 `document_product_categories` 改为从 `文档-产品关联文档` 读取,`document_application_scenarios` 改为从 `文档-筛选依据` 读取,`document_hotel_type` 改为从 `文档-适用场景` 读取 + - `document_status` 仅保留 `有效` / `过期` 两种状态,并由生效日期与到期日期自动计算 - 成功后会写入一条文档操作历史,类型为 `create` - `POST /pb/api/document/update` - 按 `document_id` 更新文档 - `document_title`、`document_type` 为必填;其余字段均允许为空 - 若传入附件字段,则会校验多个 `attachments_id` 是否都存在 + - 多选字段的持久化格式与新增接口一致 - 成功后会写入一条文档操作历史,类型为 `update` - `POST /pb/api/document/delete` - 按 `document_id` 真删除文档 @@ -325,6 +331,8 @@ - 指定字典查询 - 行内编辑基础字段 - 弹窗编辑枚举项 + - 为每个枚举项单独上传图片,并保存对应 `attachments_id` + - 回显字典项图片缩略图与文件流链接 - 新增 / 删除字典 - 返回主页 - 文档管理页支持: diff --git a/pocket-base/spec/openapi-manage.yaml b/pocket-base/spec/openapi-manage.yaml new file mode 100644 index 0000000..2028a77 --- /dev/null +++ b/pocket-base/spec/openapi-manage.yaml @@ -0,0 +1,1408 @@ +openapi: 3.1.0 +info: + title: BAI PocketBase Manage API + description: | + 基于 PocketBase `bai_api_pb_hooks` 的对外接口文档,可直接导入 Postman。 + 当前 `tbl_auth_users.openid` 已被定义为全平台统一身份锚点: + - 微信用户:`openid = 微信 openid` + - 平台用户:`openid = 服务端生成的 GUID` + 请在 Apifox 环境中统一设置全局 Header:`Authorization: Bearer {{token}}`。 + version: 1.0.0-manage + license: + name: Proprietary + identifier: LicenseRef-Proprietary +servers: + - url: https://bai-api.blv-oa.com + description: "生产环境" + - url: http://localhost:8090 + description: "PocketBase 本地环境" +tags: + - name: 系统 + description: "基础检查接口" + - name: 平台认证 + description: "面向平台用户的认证接口;平台用户会生成 GUID 并写入统一 `openid` 字段。" + - name: 字典管理 + description: "面向 ManagePlatform 用户的系统字典维护接口。" + - name: 附件管理 + description: "面向 ManagePlatform 用户的附件上传、查询与删除接口。" + - name: 文档管理 + description: "面向 ManagePlatform 用户的文档新增、查询、修改、删除接口;查询时会自动返回关联附件的 PocketBase 文件流链接。" + - name: 文档历史 + description: "面向 ManagePlatform 用户的文档操作历史查询接口。" +security: + - bearerAuth: [] +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + ApiResponse: + type: object + required: [code, msg, data] + properties: + code: + type: integer + example: 200 + msg: + type: string + example: 操作成功 + data: + type: object + additionalProperties: true + HealthData: + type: object + properties: + status: + type: string + example: healthy + version: + type: string + description: "当前已部署 hooks 版本号,用于确认发布是否生效" + example: 2026.03.26-health-probe.1 + timestamp: + type: string + format: date-time + UsersCountData: + type: object + properties: + total_users: + type: integer + description: "tbl_auth_users 表中的用户总数" + example: 128 + HelloWorldData: + type: object + properties: + message: + type: string + example: Hello, World! + timestamp: + type: string + format: date-time + status: + type: string + example: success + build_time: + type: + - string + - 'null' + format: date-time + CompanyInfo: + anyOf: + - type: object + additionalProperties: true + - type: 'null' + UserInfo: + type: object + description: | + 统一用户视图。 + 其中 `openid` 为全平台统一身份标识:微信用户使用微信 openid,平台用户使用服务端生成 GUID。 + properties: + pb_id: + type: string + users_convers_id: + type: string + users_id: + type: string + users_idtype: + type: string + description: "用户身份来源类型" + enum: [WeChat, ManagePlatform] + users_id_number: + type: string + users_status: + type: string + users_rank_level: + type: number + users_auth_type: + type: number + users_type: + type: string + enum: [游客, 注册用户] + users_name: + type: string + users_phone: + type: string + users_phone_masked: + type: string + users_level: + type: string + users_picture: + type: string + openid: + type: string + description: "全平台统一身份标识;微信用户为微信 openid,平台用户为服务端生成的 GUID" + company_id: + type: string + users_parent_id: + type: string + users_promo_code: + type: string + usergroups_id: + type: string + company: + $ref: '#/components/schemas/CompanyInfo' + created: + type: string + updated: + type: string + PocketBaseAuthResponse: + type: object + description: | + 项目统一认证响应。 + 所有对外接口统一返回 `code`、`msg`、`data`,认证成功时额外返回顶层 `token`。 + properties: + code: + type: integer + example: 200 + msg: + type: string + example: 登录成功 + data: + type: object + properties: + status: + type: string + enum: [register_success, login_success] + is_info_complete: + type: boolean + user: + $ref: '#/components/schemas/UserInfo' + token: + type: string + description: "PocketBase 原生 auth token;仅认证类接口在成功时额外返回" + example: + code: 200 + msg: 登录成功 + data: + status: login_success + is_info_complete: true + user: + pb_id: vtukf6agem2xbcv + users_id: U202603260001 + users_idtype: ManagePlatform + users_name: momo + users_phone: '13509214696' + users_phone_masked: '135****4696' + users_status: '' + users_rank_level: 0 + users_auth_type: 0 + users_type: 注册用户 + users_picture: '' + openid: app_momo + company_id: '' + users_parent_id: '' + users_promo_code: '' + usergroups_id: '' + company: null + created: '' + updated: '' + token: eyJhbGciOi... + WechatLoginRequest: + type: object + required: [users_wx_code] + description: "微信小程序登录/注册请求体。" + properties: + users_wx_code: + type: string + description: "微信小程序登录临时凭证 code" + example: 0a1b2c3d4e5f6g + WechatProfileRequest: + type: object + required: [users_name, users_phone_code, users_picture] + description: "微信用户资料完善请求体。" + properties: + users_name: + type: string + example: 张三 + users_phone_code: + type: string + example: 2b7d9f2e3c4a5b6d7e8f + users_picture: + type: string + example: https://example.com/avatar.png + WechatProfileResponseData: + type: object + properties: + status: + type: string + enum: [update_success] + user: + $ref: '#/components/schemas/UserInfo' + PlatformRegisterRequest: + type: object + required: [users_name, users_phone, password, passwordConfirm, users_picture] + description: "平台用户注册请求体;注册成功后将生成 GUID 并写入统一 `openid` 字段。" + properties: + users_name: + type: string + example: 张三 + users_phone: + type: string + example: 13800138000 + password: + type: string + example: 12345678 + passwordConfirm: + type: string + example: 12345678 + users_picture: + type: string + example: https://example.com/avatar.png + users_id_number: + type: string + users_level: + type: string + users_type: + type: string + company_id: + type: string + users_parent_id: + type: string + users_promo_code: + type: string + usergroups_id: + type: string + PlatformLoginRequest: + type: object + required: [login_account, password] + description: "平台用户登录请求体;前端使用邮箱或手机号 + 密码提交,服务端内部转换为 PocketBase 原生 password auth。" + properties: + login_account: + type: string + description: "支持邮箱或手机号" + example: admin@example.com + password: + type: string + example: 12345678 + SystemRefreshTokenRequest: + type: object + description: | + 系统刷新 token 请求体。 + `users_wx_code` 允许为空。 + 当 `Authorization` 对应 token 有效时,可不传或传空; + 当 token 失效时,需提供 `users_wx_code` 走微信 code 重新签发流程。 + properties: + users_wx_code: + type: + - string + - 'null' + description: "微信小程序登录临时凭证 code" + example: 0a1b2c3d4e5f6g + RefreshTokenData: + type: object + properties: {} + DictionaryItem: + type: object + required: [enum, description, sortOrder] + properties: + enum: + type: string + example: enabled + description: + type: string + example: 启用 + image: + type: string + description: "对应图片附件的 `attachments_id`,允许为空" + example: ATT-1743037200000-abc123 + imageUrl: + type: string + description: "根据 `image -> tbl_attachments` 自动解析出的图片文件流链接" + imageAttachment: + anyOf: + - $ref: '#/components/schemas/AttachmentRecord' + - type: 'null' + sortOrder: + type: integer + example: 1 + DictionaryRecord: + type: object + properties: + pb_id: + type: string + system_dict_id: + type: string + dict_name: + type: string + dict_word_is_enabled: + type: boolean + dict_word_parent_id: + type: string + dict_word_remark: + type: string + items: + type: array + items: + $ref: '#/components/schemas/DictionaryItem' + created: + type: string + updated: + type: string + DictionaryListRequest: + type: object + properties: + keyword: + type: string + description: "对 `dict_name` 的模糊搜索关键字" + example: 状态 + DictionaryDetailRequest: + type: object + required: [dict_name] + properties: + dict_name: + type: string + example: 用户状态 + DictionaryMutationRequest: + type: object + required: [dict_name, items] + properties: + original_dict_name: + type: string + description: "更新时用于定位原始记录;新增时可不传" + example: 用户状态 + dict_name: + type: string + example: 用户状态 + dict_word_is_enabled: + type: boolean + example: true + dict_word_parent_id: + type: string + example: '' + dict_word_remark: + type: string + example: 系统状态字典 + items: + type: array + minItems: 1 + description: "每项会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order`" + items: + $ref: '#/components/schemas/DictionaryItem' + DictionaryDeleteRequest: + type: object + required: [dict_name] + properties: + 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 + description: "多选时按 `system_dict_id@dict_word_enum|...` 保存" + 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_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_attachments: + type: array + items: + $ref: '#/components/schemas/AttachmentRecord' + document_image_attachment: + anyOf: + - $ref: '#/components/schemas/AttachmentRecord' + - type: 'null' + document_video: + type: string + 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_attachments: + type: array + items: + $ref: '#/components/schemas/AttachmentRecord' + document_video_attachment: + anyOf: + - $ref: '#/components/schemas/AttachmentRecord' + - type: 'null' + 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 + description: "文档状态,仅允许 `有效` 或 `过期`,由系统根据生效日期和到期日期自动计算;当两者都为空时默认 `有效`" + 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 + description: "支持按存储值过滤;多选时格式为 `system_dict_id@dict_word_enum|...`" + example: 说明书 + DocumentDetailRequest: + type: object + required: [document_id] + properties: + document_id: + type: string + example: DOC-1743037200000-abc123 + DocumentMutationRequest: + type: object + required: [document_title, document_type] + 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 + description: "必填;前端显示为字典项描述,存库时按 `system_dict_id@dict_word_enum|...` 保存" + document_title: + type: string + document_subtitle: + type: string + document_summary: + type: string + document_content: + type: string + document_image: + oneOf: + - type: string + description: "多个图片附件 id 使用 `|` 分隔" + - type: array + items: + type: string + description: "图片附件 id 列表;支持数组或 `|` 分隔字符串" + document_video: + oneOf: + - type: string + description: "多个视频附件 id 使用 `|` 分隔" + - type: array + items: + type: string + description: "视频附件 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 + description: "文档状态,仅允许 `有效` 或 `过期`,由系统根据生效日期和到期日期自动计算;当两者都为空时默认 `有效`" + 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: + /pb/api/system/test-helloworld: + post: + tags: [系统] + summary: HelloWorld 测试接口 + responses: + '200': + description: "成功" + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/HelloWorldData' + /pb/api/system/health: + post: + tags: [系统] + summary: 健康检查 + responses: + '200': + description: "成功" + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/HealthData' + /pb/api/system/users-count: + post: + tags: [系统] + summary: 查询用户总数 + description: "统计 `tbl_auth_users` 集合中的记录总数,并返回一个数值。" + responses: + '200': + description: "成功" + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/UsersCountData' + /pb/api/platform/register: + post: + tags: [平台认证] + summary: 平台用户注册 + description: | + 创建平台用户 auth record。 + 服务端会自动生成 GUID 并写入统一身份字段 `openid`,同时写入 `users_idtype = ManagePlatform`。 + 前端以 `users_phone + password/passwordConfirm` 注册,但服务端仍会创建 PocketBase 原生 auth 用户。 + 注册成功后直接返回 PocketBase 原生 auth token。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformRegisterRequest' + responses: + '200': + description: "注册成功" + content: + application/json: + schema: + $ref: '#/components/schemas/PocketBaseAuthResponse' + '400': + description: "参数错误或手机号已存在" + '500': + description: "GUID 生成失败、auth identity 缺失或保存用户失败" + '415': + description: "请求体必须为 application/json" + '429': + description: "重复请求过于频繁" + /pb/api/platform/login: + post: + tags: [平台认证] + summary: 平台用户登录 + description: | + 前端使用平台注册时保存的 `邮箱或手机号 + password` 登录。 + 仅允许 `users_idtype = ManagePlatform` 的用户通过该接口登录。 + 服务端会根据 `login_account` 自动判断邮箱或手机号,并定位平台用户,再使用该用户的 PocketBase 原生 identity(当前为 `email`)执行原生 password auth。 + 登录成功后直接返回 PocketBase 原生 auth token。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformLoginRequest' + example: + login_account: 13509214696 + password: Momo123456 + responses: + '200': + description: "登录成功" + content: + application/json: + schema: + $ref: '#/components/schemas/PocketBaseAuthResponse' + example: + code: 200 + msg: 登录成功 + data: + status: login_success + is_info_complete: false + user: + pb_id: vtukf6agem2xbcv + users_id: '' + users_idtype: ManagePlatform + users_name: momo + users_phone: '13509214696' + users_phone_masked: '135****4696' + users_status: '' + users_rank_level: 0 + users_auth_type: 0 + users_type: '' + users_picture: '' + openid: app_momo + company_id: '' + users_parent_id: '' + users_promo_code: '' + usergroups_id: '' + company: null + created: '' + updated: '' + token: eyJhbGciOi... + '400': + description: "参数错误、密码错误或用户类型不匹配" + '404': + description: "平台用户不存在" + '500': + description: "平台用户缺少原生登录 identity 或服务端内部错误" + '415': + description: "请求体必须为 application/json" + '429': + description: "重复请求过于频繁" + /pb/api/dictionary/list: + post: + tags: [字典管理] + summary: 查询字典列表 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 支持按 `dict_name` 模糊搜索,返回字典全量信息,并将三个聚合字段组装为 `items`。 + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/DictionaryListRequest' + 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/DictionaryRecord' + '401': + description: "token 无效或已过期" + '403': + description: "非 ManagePlatform 用户无权访问" + '415': + description: "请求体必须为 application/json" + /pb/api/dictionary/detail: + post: + tags: [字典管理] + summary: 查询指定字典 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 按唯一键 `dict_name` 查询单条字典,并返回组装后的 `items`。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DictionaryDetailRequest' + responses: + '200': + description: "查询成功" + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/DictionaryRecord' + '400': + description: "参数错误" + '401': + description: "token 无效或已过期" + '403': + description: "非 ManagePlatform 用户无权访问" + '404': + description: "未找到对应字典" + '415': + description: "请求体必须为 application/json" + /pb/api/dictionary/create: + post: + tags: [字典管理] + summary: 新增字典 + description: | + 仅允许 `ManagePlatform` 用户访问。 + `system_dict_id` 由服务端自动生成;`dict_name` 必须唯一; + `items` 会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order` 四个字段。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DictionaryMutationRequest' + responses: + '200': + description: "新增成功" + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/DictionaryRecord' + '400': + description: "参数错误或 dict_name 已存在" + '401': + description: "token 无效或已过期" + '403': + description: "非 ManagePlatform 用户无权访问" + '415': + description: "请求体必须为 application/json" + '429': + description: "重复请求过于频繁" + /pb/api/dictionary/update: + post: + tags: [字典管理] + summary: 修改字典 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 根据 `original_dict_name`(未传时回退为 `dict_name`)定位原记录并更新。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DictionaryMutationRequest' + responses: + '200': + description: "修改成功" + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/DictionaryRecord' + '400': + description: "参数错误或 dict_name 冲突" + '401': + description: "token 无效或已过期" + '403': + description: "非 ManagePlatform 用户无权访问" + '404': + description: "未找到待修改字典" + '415': + description: "请求体必须为 application/json" + '429': + description: "重复请求过于频繁" + /pb/api/dictionary/delete: + post: + tags: [字典管理] + summary: 删除字典 + description: | + 仅允许 `ManagePlatform` 用户访问。 + 按 `dict_name` 真删除对应记录。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DictionaryDeleteRequest' + responses: + '200': + description: "删除成功" + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + type: object + properties: + dict_name: + type: string + '400': + description: "参数错误或删除失败" + '401': + description: "token 无效或已过期" + '403': + description: "非 ManagePlatform 用户无权访问" + '404': + description: "未找到待删除字典" + '415': + 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` 中的多个 `attachments_id` 关联 `tbl_attachments`, + 额外补充 `document_image_urls`、`document_video_urls` 以及对应附件对象数组。 + 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_title`、`document_type` 为必填;其余字段均允许为空。 + `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_title`、`document_type` 为必填;其余字段均允许为空。 + 若传入 `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/pocket-base/spec/openapi-wx.yaml b/pocket-base/spec/openapi-wx.yaml new file mode 100644 index 0000000..4e66d94 --- /dev/null +++ b/pocket-base/spec/openapi-wx.yaml @@ -0,0 +1,419 @@ +openapi: 3.1.0 +info: + title: BAI PocketBase WeChat API + version: 1.0.0-wx + description: | + 面向微信端的小程序接口文档。 + 本文档只包含微信登录、微信资料完善,以及微信端会共用的系统接口。 + license: + name: Proprietary + identifier: LicenseRef-Proprietary +servers: + - url: https://bai-api.blv-oa.com + description: 生产环境 + - url: http://localhost:8090 + description: PocketBase 本地环境 +tags: + - name: 系统 + description: 微信端共用系统接口 + - name: 微信认证 + description: 微信登录、资料完善与 token 刷新接口 +paths: + /pb/api/system/users-count: + post: + operationId: postSystemUsersCount + security: [] + tags: + - 系统 + summary: 查询用户总数 + description: 统计 `tbl_auth_users` 集合中的记录总数。 + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + $ref: '#/components/schemas/UsersCountResponse' + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: 服务端错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /pb/api/system/refresh-token: + post: + operationId: postSystemRefreshToken + security: + - bearerAuth: [] + - {} + tags: + - 系统 + summary: 刷新认证 token + description: | + 当当前 `Authorization` 仍有效时,直接基于当前 auth 用户续签。 + 当 token 失效时,可传入 `users_wx_code` 走微信 code 重新签发。 + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SystemRefreshTokenRequest' + responses: + '200': + description: 刷新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/RefreshTokenResponse' + '400': + description: 参数错误或微信 code 换取失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: token 无效,且未提供有效的 `users_wx_code` + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: 当前用户不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '415': + description: 请求体不是 JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '429': + description: 请求过于频繁 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: 服务端错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /pb/api/wechat/login: + post: + operationId: postWechatLogin + security: [] + tags: + - 微信认证 + summary: 微信登录或首次注册 + description: | + 使用微信 `users_wx_code` 换取微信 openid。 + 若 `tbl_auth_users` 中不存在对应用户,则自动创建新 auth 用户并返回 token。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WechatLoginRequest' + responses: + '200': + description: 登录或注册成功 + content: + application/json: + schema: + $ref: '#/components/schemas/AuthSuccessResponse' + '400': + description: 参数错误或保存用户失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: 认证失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '415': + description: 请求体不是 JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '429': + description: 请求过于频繁 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: 服务端错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /pb/api/wechat/profile: + post: + operationId: postWechatProfile + security: + - bearerAuth: [] + tags: + - 微信认证 + summary: 更新微信用户资料 + description: | + 基于当前 `Authorization` 对应的 auth 用户更新昵称、手机号和头像。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WechatProfileRequest' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/WechatProfileResponse' + '400': + description: 参数错误、手机号已被占用或资料更新失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: token 无效或缺少 openid + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '415': + description: 请求体不是 JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '429': + description: 请求过于频繁 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: 服务端错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + ApiResponseBase: + type: object + required: + - code + - msg + - data + properties: + code: + type: integer + example: 200 + msg: + type: string + example: 操作成功 + data: + type: object + additionalProperties: true + ErrorResponse: + type: object + required: + - code + - msg + - data + properties: + code: + type: integer + example: 400 + msg: + type: string + example: 请求失败 + data: + type: object + additionalProperties: true + CompanyInfo: + anyOf: + - type: object + additionalProperties: true + - type: 'null' + UserInfo: + type: object + properties: + pb_id: + type: string + users_convers_id: + type: string + users_id: + type: string + users_idtype: + type: string + enum: + - WeChat + - ManagePlatform + users_id_number: + type: string + users_type: + type: string + users_name: + type: string + users_status: + type: + - string + - number + users_rank_level: + type: + - number + - integer + users_auth_type: + type: + - number + - integer + users_phone: + type: string + users_phone_masked: + type: string + users_level: + type: string + users_picture: + type: string + openid: + type: string + description: 全平台统一身份标识 + company_id: + type: string + users_parent_id: + type: string + users_promo_code: + type: string + usergroups_id: + type: string + company: + $ref: '#/components/schemas/CompanyInfo' + created: + type: string + updated: + type: string + WechatLoginRequest: + type: object + required: + - users_wx_code + properties: + users_wx_code: + type: string + description: 微信小程序登录临时凭证 code + example: 0a1b2c3d4e5f6g + WechatProfileRequest: + type: object + required: + - users_name + - users_phone_code + - users_picture + properties: + users_name: + type: string + example: 张三 + users_phone_code: + type: string + example: 2b7d9f2e3c4a5b6d7e8f + users_picture: + type: string + example: https://example.com/avatar.png + SystemRefreshTokenRequest: + type: object + properties: + users_wx_code: + type: + - string + - 'null' + description: | + 可选。 + 当前 token 失效时,可通过该 code 重新签发 token。 + example: 0a1b2c3d4e5f6g + AuthSuccessData: + type: object + properties: + status: + type: string + enum: + - register_success + - login_success + is_info_complete: + type: boolean + user: + $ref: '#/components/schemas/UserInfo' + AuthSuccessResponse: + allOf: + - $ref: '#/components/schemas/ApiResponseBase' + - type: object + required: + - token + properties: + data: + $ref: '#/components/schemas/AuthSuccessData' + token: + type: string + description: PocketBase 原生 auth token + WechatProfileResponseData: + type: object + properties: + status: + type: string + enum: + - update_success + user: + $ref: '#/components/schemas/UserInfo' + WechatProfileResponse: + allOf: + - $ref: '#/components/schemas/ApiResponseBase' + - type: object + properties: + data: + $ref: '#/components/schemas/WechatProfileResponseData' + RefreshTokenResponse: + allOf: + - $ref: '#/components/schemas/ApiResponseBase' + - type: object + required: + - token + properties: + data: + type: object + additionalProperties: true + example: {} + token: + type: string + description: 新签发的 PocketBase 原生 auth token + UsersCountData: + type: object + properties: + total_users: + type: integer + example: 128 + UsersCountResponse: + allOf: + - $ref: '#/components/schemas/ApiResponseBase' + - type: object + properties: + data: + $ref: '#/components/schemas/UsersCountData' diff --git a/pocket-base/spec/openapi.yaml b/pocket-base/spec/openapi.yaml index b281859..33ffd8e 100644 --- a/pocket-base/spec/openapi.yaml +++ b/pocket-base/spec/openapi.yaml @@ -292,6 +292,17 @@ components: description: type: string example: 启用 + image: + type: string + description: 对应图片附件的 `attachments_id`,允许为空 + example: ATT-1743037200000-abc123 + imageUrl: + type: string + description: 根据 `image -> tbl_attachments` 自动解析出的图片文件流链接 + imageAttachment: + allOf: + - $ref: '#/components/schemas/AttachmentRecord' + nullable: true sortOrder: type: integer example: 1 @@ -355,6 +366,7 @@ components: items: type: array minItems: 1 + description: 每项会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order` items: $ref: '#/components/schemas/DictionaryItem' DictionaryDeleteRequest: @@ -457,6 +469,7 @@ components: type: string document_type: type: string + description: 多选时按 `system_dict_id@dict_word_enum|...` 保存 document_title: type: string document_subtitle: @@ -520,7 +533,7 @@ components: type: string document_keywords: type: string - description: 多值字段,使用 `|` 分隔 + description: 固定字典多选字段,使用 `|` 分隔 document_share_count: type: number document_download_count: @@ -529,6 +542,7 @@ components: type: number document_status: type: string + description: 文档状态,仅允许 `有效` 或 `过期`,由系统根据生效日期和到期日期自动计算;当两者都为空时默认 `有效` document_embedding_status: type: string document_embedding_error: @@ -539,13 +553,13 @@ components: type: string document_product_categories: type: string - description: 多值字段,使用 `|` 分隔 + description: 固定字典多选字段,使用 `|` 分隔 document_application_scenarios: type: string - description: 多值字段,使用 `|` 分隔 + description: 固定字典多选字段,使用 `|` 分隔 document_hotel_type: type: string - description: 多值字段,使用 `|` 分隔 + description: 固定字典多选字段,使用 `|` 分隔 document_remark: type: string created: @@ -564,6 +578,7 @@ components: example: active document_type: type: string + description: 支持按存储值过滤;多选时格式为 `system_dict_id@dict_word_enum|...` example: 说明书 DocumentDetailRequest: type: object @@ -590,6 +605,7 @@ components: example: 2027-03-27 document_type: type: string + description: 必填;前端显示为字典项描述,存库时按 `system_dict_id@dict_word_enum|...` 保存 document_title: type: string document_subtitle: @@ -618,7 +634,7 @@ components: type: string document_keywords: type: string - description: 多值字段,使用 `|` 分隔 + description: 从 `文档-关键词` 字典多选后使用 `|` 分隔保存 document_share_count: type: number document_download_count: @@ -627,6 +643,7 @@ components: type: number document_status: type: string + description: 文档状态,仅允许 `有效` 或 `过期`,由系统根据生效日期和到期日期自动计算;当两者都为空时默认 `有效` document_embedding_status: type: string document_embedding_error: @@ -637,13 +654,13 @@ components: type: string document_product_categories: type: string - description: 多值字段,使用 `|` 分隔 + description: 从 `文档-产品关联文档` 字典多选后使用 `|` 分隔保存 document_application_scenarios: type: string - description: 多值字段,使用 `|` 分隔 + description: 从 `文档-筛选依据` 字典多选后使用 `|` 分隔保存 document_hotel_type: type: string - description: 多值字段,使用 `|` 分隔 + description: 从 `文档-适用场景` 字典多选后使用 `|` 分隔保存 document_remark: type: string DocumentDeleteRequest: @@ -1005,7 +1022,7 @@ paths: description: | 仅允许 `ManagePlatform` 用户访问。 `system_dict_id` 由服务端自动生成;`dict_name` 必须唯一; - `items` 会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 三个字段。 + `items` 会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order` 四个字段。 requestBody: required: true content: diff --git a/script/package.json b/script/package.json index d2c1350..92378ec 100644 --- a/script/package.json +++ b/script/package.json @@ -6,7 +6,8 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "init:newpb": "node pocketbase.newpb.js", - "init:documents": "node pocketbase.documents.js" + "init:documents": "node pocketbase.documents.js", + "init:dictionary": "node pocketbase.dictionary.js" }, "keywords": [], "author": "", diff --git a/script/pocketbase.dictionary.js b/script/pocketbase.dictionary.js new file mode 100644 index 0000000..83c9658 --- /dev/null +++ b/script/pocketbase.dictionary.js @@ -0,0 +1,195 @@ +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 collectionData = { + name: 'tbl_system_dict', + type: 'base', + fields: [ + { name: 'system_dict_id', type: 'text', required: true }, + { name: 'dict_name', type: 'text', required: true }, + { name: 'dict_word_enum', type: 'text' }, + { name: 'dict_word_description', type: 'text' }, + { name: 'dict_word_image', type: 'text' }, + { name: 'dict_word_is_enabled', type: 'bool' }, + { name: 'dict_word_sort_order', type: 'text' }, + { name: 'dict_word_parent_id', type: 'text' }, + { name: 'dict_word_remark', type: 'text' }, + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_system_dict_id ON tbl_system_dict (system_dict_id)', + 'CREATE UNIQUE INDEX idx_dict_name ON tbl_system_dict (dict_name)', + 'CREATE INDEX idx_dict_word_parent_id ON tbl_system_dict (dict_word_parent_id)', + ], +}; + +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; + } + + return payload; +} + +function buildCollectionPayload(target, existingCollection) { + if (!existingCollection) { + return { + name: target.name, + type: target.type, + fields: target.fields.map((field) => normalizeFieldPayload(field, null)), + indexes: target.indexes, + }; + } + + const targetFieldMap = new Map(target.fields.map((field) => [field.name, field])); + const fields = (existingCollection.fields || []).map((existingField) => { + const nextField = targetFieldMap.get(existingField.name); + if (!nextField) { + return existingField; + } + + targetFieldMap.delete(existingField.name); + return normalizeFieldPayload(nextField, existingField); + }); + + for (const field of targetFieldMap.values()) { + fields.push(normalizeFieldPayload(field, null)); + } + + return { + name: target.name, + type: target.type, + fields: fields, + indexes: target.indexes, + }; +} + +function normalizeFieldList(fields) { + return (fields || []).map((field) => ({ + name: field.name, + type: field.type, + required: !!field.required, + })); +} + +async function createOrUpdateCollection(target) { + console.log(`🔄 正在处理表: ${target.name} ...`); + + try { + const existing = await pb.collections.getOne(target.name); + await pb.collections.update(existing.id, buildCollectionPayload(target, existing)); + console.log(`♻️ ${target.name} 已存在,已按最新结构更新。`); + } catch (error) { + if (error.status === 404) { + await pb.collections.create(buildCollectionPayload(target, null)); + console.log(`✅ ${target.name} 创建完成。`); + return; + } + + console.error(`❌ 处理集合 ${target.name} 失败:`, { + status: error.status, + message: error.message, + response: error.response?.data, + }); + throw error; + } +} + +async function verifyCollection(target) { + console.log('\n🔍 开始校验字典表结构与索引...'); + 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 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)) { + missingFields.push(field.name); + continue; + } + + 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 || []); + const missingIndexes = target.indexes.filter((indexSql) => !remoteIndexes.has(indexSql)); + + if (!missingFields.length && !mismatchedTypes.length && !mismatchedRequired.length && !missingIndexes.length) { + console.log(`✅ ${target.name} 校验通过。`); + return; + } + + if (missingFields.length) { + console.log(` - 缺失字段: ${missingFields.join(', ')}`); + } + if (mismatchedTypes.length) { + console.log(` - 字段类型不匹配: ${mismatchedTypes.join(', ')}`); + } + if (mismatchedRequired.length) { + console.log(` - 字段必填属性不匹配: ${mismatchedRequired.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 载入认证状态。'); + + await createOrUpdateCollection(collectionData); + await verifyCollection(collectionData); + console.log('\n🎉 字典表结构初始化并校验完成!'); + } catch (error) { + console.error('❌ 初始化失败:', error.response?.data || error.message); + process.exitCode = 1; + } +} + +init(); diff --git a/script/pocketbase.documents.js b/script/pocketbase.documents.js index 0bfe684..dd1ccb7 100644 --- a/script/pocketbase.documents.js +++ b/script/pocketbase.documents.js @@ -26,7 +26,7 @@ const collections = [ type: 'base', fields: [ { name: 'attachments_id', type: 'text', required: true }, - { name: 'attachments_link', type: 'file', maxSelect: 0, maxSize: 104857600, mimeTypes: [] }, + { name: 'attachments_link', type: 'file', maxSelect: 0, maxSize: 4294967296, mimeTypes: [] }, { name: 'attachments_filename', type: 'text' }, { name: 'attachments_filetype', type: 'text' }, { name: 'attachments_size', type: 'number' },