feat: 规范化PocketBase数据库文档与原生API访问

- 将数据库文档拆分为按collection命名的标准文件,统一格式
- 补充tbl_company、tbl_system_dict等表的原生访问规则
- 新增users_tag、document_create等字段
- 优化用户资料更新接口,支持非必填字段
- 添加公司原生API测试脚本
- 归档本次变更至OpenSpec
This commit is contained in:
2026-03-29 16:21:34 +08:00
parent 51a90260e4
commit e9fe1165e3
46 changed files with 3790 additions and 1108 deletions

View File

@@ -23,6 +23,7 @@ require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/system/users-count.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/system/refresh-token.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/platform/login.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/platform/register.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/company/records-create.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/list.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/detail.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/create.js`)

View File

@@ -0,0 +1,17 @@
onRecordAfterCreateSuccess((e) => {
try {
const authRecord = e && e.auth ? e.auth : null
if (!authRecord || authRecord.collection().name !== 'tbl_auth_users') {
return
}
if (authRecord.getString('users_type') === '服务商') {
return
}
authRecord.set('users_type', '服务商')
$app.save(authRecord)
} catch (_error) {
// Keep company create flow unchanged even if user type sync fails.
}
}, 'tbl_company')

View File

@@ -6,7 +6,6 @@ routerAdd('POST', '/api/dictionary/detail', function (e) {
try {
guards.requireJson(e)
guards.requireManagePlatformUser(e)
const payload = guards.validateDictionaryDetailBody(e)
const data = dictionaryService.getDictionaryByName(payload.dict_name)
@@ -25,4 +24,4 @@ routerAdd('POST', '/api/dictionary/detail', function (e) {
data: (err && err.data) || {},
})
}
})
})

View File

@@ -6,7 +6,6 @@ routerAdd('POST', '/api/dictionary/list', function (e) {
try {
guards.requireJson(e)
guards.requireManagePlatformUser(e)
const payload = guards.validateDictionaryListBody(e)
const data = dictionaryService.listDictionaries(payload.keyword)
@@ -27,4 +26,4 @@ routerAdd('POST', '/api/dictionary/list', function (e) {
data: (err && err.data) || {},
})
}
})
})

View File

@@ -6,7 +6,5 @@ module.exports = {
POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTgwNTk2MzM4NywiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.R34MTotuFBpevqbbUtvQ3GPa0Z1mpY8eQwZgDdmfhMo',
WECHAT_APPID: 'wx3bd7a7b19679da7a',
WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1',
/* WECHAT_APPID: 'wx42e9add0f91af98b',
WECHAT_SECRET: '5620f00b40297efaf3d197d61ae184d6', */
BUILD_TIME: '',
}

View File

@@ -25,13 +25,12 @@ function validateLoginBody(e) {
function validateProfileBody(e) {
const payload = parseBody(e)
if (!payload.users_name) throw createAppError(400, 'users_name 为必填项')
if (!payload.users_phone_code) throw createAppError(400, 'users_phone_code 为必填项')
if (!payload.users_picture) throw createAppError(400, 'users_picture 为必填项')
return {
users_name: payload.users_name,
users_phone_code: payload.users_phone_code,
users_picture: payload.users_picture,
users_name: Object.prototype.hasOwnProperty.call(payload, 'users_name') ? String(payload.users_name || '').trim() : undefined,
users_phone_code: Object.prototype.hasOwnProperty.call(payload, 'users_phone_code') ? String(payload.users_phone_code || '').trim() : undefined,
users_phone: Object.prototype.hasOwnProperty.call(payload, 'users_phone') ? String(payload.users_phone || '').trim() : undefined,
users_tag: Object.prototype.hasOwnProperty.call(payload, 'users_tag') ? String(payload.users_tag || '').trim() : undefined,
users_picture: Object.prototype.hasOwnProperty.call(payload, 'users_picture') ? String(payload.users_picture || '').trim() : undefined,
users_id_pic_a: Object.prototype.hasOwnProperty.call(payload, 'users_id_pic_a') ? payload.users_id_pic_a : undefined,
users_id_pic_b: Object.prototype.hasOwnProperty.call(payload, 'users_id_pic_b') ? payload.users_id_pic_b : undefined,
users_title_picture: Object.prototype.hasOwnProperty.call(payload, 'users_title_picture') ? payload.users_title_picture : undefined,
@@ -60,6 +59,7 @@ function validatePlatformRegisterBody(e) {
users_id_number: payload.users_id_number || '',
users_level: payload.users_level || '',
users_type: payload.users_type || '',
users_tag: payload.users_tag || '',
company_id: payload.company_id || '',
users_parent_id: payload.users_parent_id || '',
users_promo_code: payload.users_promo_code || '',

View File

@@ -206,6 +206,7 @@ function exportDocumentRecord(record) {
return {
pb_id: record.id,
document_id: record.getString('document_id'),
document_create: String(record.get('document_create') || ''),
document_effect_date: String(record.get('document_effect_date') || ''),
document_expiry_date: String(record.get('document_expiry_date') || ''),
document_type: record.getString('document_type'),

View File

@@ -122,7 +122,30 @@ function getCompanyByCompanyId(companyId) {
function exportCompany(companyRecord) {
if (!companyRecord) return null
return companyRecord.publicExport()
return {
pb_id: companyRecord.id,
company_id: companyRecord.getString('company_id'),
company_name: companyRecord.getString('company_name'),
company_type: companyRecord.getString('company_type'),
company_entity: companyRecord.getString('company_entity'),
company_usci: companyRecord.getString('company_usci'),
company_nationality: companyRecord.getString('company_nationality'),
company_nationality_code: companyRecord.getString('company_nationality_code'),
company_province: companyRecord.getString('company_province'),
company_province_code: companyRecord.getString('company_province_code'),
company_city: companyRecord.getString('company_city'),
company_city_code: companyRecord.getString('company_city_code'),
company_district: companyRecord.getString('company_district'),
company_district_code: companyRecord.getString('company_district_code'),
company_postalcode: companyRecord.getString('company_postalcode'),
company_add: companyRecord.getString('company_add'),
company_status: companyRecord.getString('company_status'),
company_level: companyRecord.getString('company_level'),
company_owner_openid: companyRecord.getString('company_owner_openid'),
company_remark: companyRecord.getString('company_remark'),
created: String(companyRecord.created || ''),
updated: String(companyRecord.updated || ''),
}
}
function resolveUserAttachment(attachmentId) {
@@ -167,15 +190,23 @@ function ensureAttachmentIdExists(attachmentId, fieldName) {
function applyUserAttachmentFields(record, payload) {
if (!payload) return
record.set('users_picture', ensureAttachmentIdExists(payload.users_picture || '', 'users_picture'))
if (typeof payload.users_picture !== 'undefined' && payload.users_picture) {
record.set('users_picture', ensureAttachmentIdExists(payload.users_picture, 'users_picture'))
}
if (typeof payload.users_id_pic_a !== 'undefined') {
record.set('users_id_pic_a', ensureAttachmentIdExists(payload.users_id_pic_a || '', 'users_id_pic_a'))
if (payload.users_id_pic_a) {
record.set('users_id_pic_a', ensureAttachmentIdExists(payload.users_id_pic_a, 'users_id_pic_a'))
}
}
if (typeof payload.users_id_pic_b !== 'undefined') {
record.set('users_id_pic_b', ensureAttachmentIdExists(payload.users_id_pic_b || '', 'users_id_pic_b'))
if (payload.users_id_pic_b) {
record.set('users_id_pic_b', ensureAttachmentIdExists(payload.users_id_pic_b, 'users_id_pic_b'))
}
}
if (typeof payload.users_title_picture !== 'undefined') {
record.set('users_title_picture', ensureAttachmentIdExists(payload.users_title_picture || '', 'users_title_picture'))
if (payload.users_title_picture) {
record.set('users_title_picture', ensureAttachmentIdExists(payload.users_title_picture, 'users_title_picture'))
}
}
}
@@ -202,6 +233,7 @@ function enrichUser(userRecord) {
users_phone: userRecord.getString('users_phone'),
users_phone_masked: maskPhone(userRecord.getString('users_phone')),
users_level: userRecord.getString('users_level'),
users_tag: userRecord.getString('users_tag'),
users_picture: userPicture.id,
users_picture_url: userPicture.url,
users_picture_attachment: userPicture.attachment,
@@ -366,6 +398,7 @@ function registerPlatformUser(payload) {
record.set('users_id_number', payload.users_id_number || '')
record.set('users_level', payload.users_level || '')
record.set('users_type', payload.users_type || REGISTERED_USER_TYPE)
record.set('users_tag', payload.users_tag || '')
record.set('company_id', payload.company_id || '')
record.set('users_parent_id', payload.users_parent_id || '')
record.set('users_promo_code', payload.users_promo_code || '')
@@ -481,7 +514,12 @@ function updateWechatUserProfile(usersWxOpenid, payload) {
throw createAppError(404, '未找到待编辑的用户')
}
const usersPhone = wechatService.getWxPhoneNumber(payload.users_phone_code)
let usersPhone = ''
if (payload.users_phone_code) {
usersPhone = wechatService.getWxPhoneNumber(payload.users_phone_code)
} else if (payload.users_phone) {
usersPhone = String(payload.users_phone || '').trim()
}
if (usersPhone && usersPhone !== currentUser.getString('users_phone')) {
const samePhoneUsers = $app.findRecordsByFilter('tbl_auth_users', 'users_phone = {:phone}', '', 10, 0, {
@@ -494,15 +532,19 @@ function updateWechatUserProfile(usersWxOpenid, payload) {
}
}
const shouldPromote = isAllProfileFieldsEmpty(currentUser)
&& !!payload.users_name
&& !!usersPhone
&& !!payload.users_picture
&& ((currentUser.getString('users_type') || GUEST_USER_TYPE) === GUEST_USER_TYPE)
currentUser.set('users_name', payload.users_name)
currentUser.set('users_phone', usersPhone)
if (payload.users_name) {
currentUser.set('users_name', payload.users_name)
}
if (usersPhone) {
currentUser.set('users_phone', usersPhone)
}
if (typeof payload.users_tag !== 'undefined' && payload.users_tag) {
currentUser.set('users_tag', payload.users_tag)
}
applyUserAttachmentFields(currentUser, payload)
const shouldPromote = ((currentUser.getString('users_type') || GUEST_USER_TYPE) === GUEST_USER_TYPE)
&& isInfoComplete(currentUser)
if (shouldPromote) {
currentUser.set('users_type', REGISTERED_USER_TYPE)
}

View File

@@ -65,12 +65,15 @@ routerAdd('GET', '/manage/document-manage', function (e) {
.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; }
.thumb { width: 72px; height: 72px; border-radius: 12px; object-fit: cover; border: 1px solid #dbe3f0; background: #fff; cursor: zoom-in; }
.file-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
.file-card { min-width: 0; min-height: 122px; border: 1px solid #dbe3f0; border-radius: 14px; padding: 8px; background: #fff; display: flex; flex-direction: column; gap: 6px; }
.file-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.file-card-title { flex: 1; font-weight: 700; font-size: 12px; line-height: 1.3; word-break: break-all; }
.file-card-icon { width: 24px; height: 24px; border-radius: 999px; border: 1px solid #dbe3f0; background: #fff; color: #475569; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 14px; padding: 0; }
.file-card-icon:hover { border-color: #94a3b8; background: #f8fafc; }
.file-meta { display: none; }
.thumb { width: 60px; height: 60px; border-radius: 10px; object-fit: cover; border: 1px solid #dbe3f0; background: #fff; cursor: zoom-in; }
.file-preview { width: 60px; height: 60px; border-radius: 10px; border: 1px solid #dbe3f0; background: #f8fafc; color: #334155; display: flex; align-items: center; justify-content: center; font-size: 24px; cursor: pointer; }
.thumb-strip { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
.image-viewer { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.82); z-index: 9999; }
.image-viewer.show { display: flex; }
@@ -85,6 +88,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
@media (max-width: 960px) {
.grid, .file-group { grid-template-columns: 1fr; }
.option-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.file-list { grid-template-columns: repeat(2, minmax(0, 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; }
@@ -526,15 +530,46 @@ routerAdd('GET', '/manage/document-manage', function (e) {
return sourceId ? (state.dictionariesById[sourceId] || null) : null
}
function normalizeDocumentTypeEnumValue(value) {
const raw = String(value || '').trim()
if (!raw) {
return ''
}
const separatorIndex = raw.indexOf('@')
return separatorIndex === -1 ? raw : raw.slice(separatorIndex + 1)
}
function buildDocumentTypeStorageValue() {
const sourceDict = getDocumentTypeSourceDictionary()
if (!sourceDict) {
return ''
}
return joinPipeValue(state.selections.documentTypeValues.map(function (enumValue) {
return sourceDict.system_dict_id + '@' + enumValue
}))
const sourceId = String(sourceDict.system_dict_id || '').trim()
if (!sourceId) {
return ''
}
const seen = {}
const parts = []
state.selections.documentTypeValues.forEach(function (value) {
const enumValue = normalizeDocumentTypeEnumValue(value)
if (!enumValue) {
return
}
const token = sourceId + '@' + enumValue
if (seen[token]) {
return
}
seen[token] = true
parts.push(token)
})
return joinPipeValue(parts)
}
function updateSelection(fieldName, value, checked) {
@@ -660,7 +695,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
if (state.mode === 'edit') {
formTitleEl.textContent = '编辑文档'
editorModeEl.textContent = '当前模式:编辑 ' + state.editingId
editorModeEl.textContent = '当前模式:编辑 ' + (state.editingSource && state.editingSource.document_title ? state.editingSource.document_title : state.editingId)
document.getElementById('submitBtn').textContent = '保存文档修改'
} else if (state.mode === 'create') {
formTitleEl.textContent = '新增文档'
@@ -823,29 +858,20 @@ routerAdd('GET', '/manage/document-manage', function (e) {
}
container.innerHTML = items.map(function (item, index) {
const title = pending
const title = pending
? (item.file && item.file.name ? item.file.name : ('待上传文件' + (index + 1)))
: (item.attachments_filename || item.attachments_id || ('附件' + (index + 1)))
const meta = pending
? ('大小:' + String(item.file && item.file.size ? item.file.size : 0) + ' bytes')
: ('ID' + escapeHtml(item.attachments_id || '') + (item.attachments_filetype ? ' / ' + escapeHtml(item.attachments_filetype) : ''))
const linkHtml = pending || !item.attachments_url
? ''
: '<a href="' + escapeHtml(item.attachments_url) + '" target="_blank" rel="noreferrer">打开文件</a>'
const previewUrl = getAttachmentPreviewUrl(item, pending)
const previewHtml = category === 'image'
? renderImageThumb(getAttachmentPreviewUrl(item, pending), title)
: ''
const actionLabel = pending ? '移除待上传' : '从文档移除'
? renderImageThumb(previewUrl, title)
: (previewUrl
? ('<a class="file-preview" href="' + escapeHtml(previewUrl) + '" target="_blank" rel="noreferrer">' + (category === 'video' ? '🎬' : '📄') + '</a>')
: ('<div class="file-preview">' + (category === 'video' ? '🎬' : '📄') + '</div>'))
const handler = pending ? '__removePendingAttachment' : '__removeCurrentAttachment'
return '<div class="file-card">'
+ '<div class="file-card-head"><div class="file-card-title">' + escapeHtml(title) + '</div></div>'
+ '<div class="file-card-head"><div class="file-card-title">' + escapeHtml(title) + '</div><button class="file-card-icon" type="button" title="移除" onclick="window.' + handler + '(\\'' + category + '\\',' + index + ')">×</button></div>'
+ previewHtml
+ '<div class="file-meta">' + meta + '</div>'
+ '<div class="file-actions">'
+ linkHtml
+ '<button class="btn btn-light" type="button" onclick="window.' + handler + '(\\'' + category + '\\',' + index + ')">' + actionLabel + '</button>'
+ '</div>'
+ '</div>'
}).join('')
}
@@ -860,27 +886,13 @@ routerAdd('GET', '/manage/document-manage', function (e) {
}
function renderLinks(item) {
const links = []
const imageThumbs = []
const imageUrls = Array.isArray(item.document_image_urls) ? item.document_image_urls : (item.document_image_url ? [item.document_image_url] : [])
const videoUrls = Array.isArray(item.document_video_urls) ? item.document_video_urls : (item.document_video_url ? [item.document_video_url] : [])
const fileUrls = Array.isArray(item.document_file_urls) ? item.document_file_urls : (item.document_file_url ? [item.document_file_url] : [])
for (let i = 0; i < imageUrls.length; i += 1) {
links.push('<a href="' + escapeHtml(imageUrls[i]) + '" target="_blank" rel="noreferrer">图片流' + (i + 1) + '</a>')
imageThumbs.push(renderImageThumb(imageUrls[i], '图片流' + (i + 1)))
}
for (let i = 0; i < videoUrls.length; i += 1) {
links.push('<a href="' + escapeHtml(videoUrls[i]) + '" target="_blank" rel="noreferrer">视频流' + (i + 1) + '</a>')
}
for (let i = 0; i < fileUrls.length; i += 1) {
links.push('<a href="' + escapeHtml(fileUrls[i]) + '" target="_blank" rel="noreferrer">文件流' + (i + 1) + '</a>')
}
if (!links.length) {
if (!imageUrls.length) {
return '<span class="muted">无</span>'
}
return '<div class="doc-links">' + links.join('') + '</div>'
+ (imageThumbs.length ? '<div class="thumb-strip">' + imageThumbs.join('') + '</div>' : '')
return '<div class="thumb-strip">' + renderImageThumb(imageUrls[0], '文档图片预览') + '</div>'
}
function renderTable() {
@@ -1132,7 +1144,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
fillFormFromItem(target)
updateEditorMode()
renderAttachmentEditors()
setStatus('已进入编辑模式:' + target.document_id, 'success')
setStatus('已进入编辑模式:' + (target.document_title || target.document_id), 'success')
window.scrollTo({ top: 0, behavior: 'smooth' })
}

View File

@@ -1,472 +0,0 @@
# OpenSpec 变更记录PocketBase Hooks 认证链路加固
## 日期
- 2026-03-23
## 范围
本次变更覆盖 `pocket-base/` 下 PocketBase hooks 项目的微信登录、平台注册/登录、资料更新、token 刷新、认证落库、错误可观测性、索引策略、字典管理、附件管理、文档管理、文档操作历史、页面辅助操作、健康检查版本探针、OpenAPI 鉴权与统一响应规范。
---
## 一、认证模型调整
### 1. 认证体系
- 保持 PocketBase 原生 auth token 作为唯一正式认证令牌。
- 登录与刷新响应统一为项目标准结构:`code``msg``data`,认证成功时额外返回顶层 `token`
- `authMethod` 统一使用空字符串 `''`,避免触发不必要的 MFA / login alerts 校验。
### 2. Header 规则
- 正式认证 Header 为:`Authorization: Bearer <token>`
- 非标准 Header `Open-Authorization` 不属于本项目接口定义。
- `users_wx_openid` Header 已从 active hooks 鉴权链路移除。
---
## 二、身份字段与数据模型约束
### 1. openid 作为唯一业务身份锚点
- `tbl_auth_users` 统一保留 `openid` 作为全平台身份锚点。
- 微信用户:`openid = 微信 openid`
- 平台用户:`openid = 服务端生成的 GUID`
- 业务逻辑中不再使用 `users_wx_openid`
- 用户查询、token 刷新、资料更新均基于 auth record 的 `openid`
### 2. auth 集合兼容字段
由于 `tbl_auth_users` 当前为 PocketBase `auth` 集合,登录注册时为兼容 PocketBase 原生 auth 校验,新增以下兼容策略:
- `email` 使用占位格式:
- 微信用户:`<openid>@wechat.local`
- 平台用户:`<openid>@manage.local`
- 自动生成随机密码
- 自动补齐 `passwordConfirm`
说明:
- 占位 `email` 仅用于满足 auth 集合保存条件,不代表用户真实邮箱。
- 业务主身份仍然是 `openid`
### 3. 自定义字段可空策略
- `tbl_auth_users` 的自定义字段目标约束为:除 `openid` 外,其余业务字段均允许为空。
- 已将 schema 脚本中的 `user_id` 改为非必填。
- 其余业务字段保持非必填。
### 4. 用户图片字段统一改为附件 ID 语义
- `users_picture`
- `users_id_pic_a`
- `users_id_pic_b`
- `users_title_picture`
以上字段已统一改为保存 `tbl_attachments.attachments_id`,不再直接保存 PocketBase `file` 字段或外部图片 URL。
查询用户信息时hooks 会自动联查 `tbl_attachments` 并补充:
- `users_picture_url`
- `users_id_pic_a_url`
- `users_id_pic_b_url`
- `users_title_picture_url`
说明:
- `tbl_attachments` 仍由附件表保存实际文件本体;
- 业务表仅负责保存附件 ID
- hooks 中原有 `ManagePlatform` 访问限制保持不变。
---
## 三、查询与排序修复
### 1. 移除无意义的 `created` 排序
在 hooks 查询中,以下查询原先使用 `'-created'` 排序:
-`openid` 查询用户
-`company_id` 查询公司
-`users_phone` 查询重复手机号
该写法在 PocketBase 当前运行场景下触发:
- `invalid sort field "created"`
现已统一移除排序参数,改为空排序字符串,因为这些查询本质上均为精确匹配或去重检查,不依赖排序。
---
## 四、错误可观测性增强
### 1. 登录路由显式错误响应
`POST /pb/api/wechat/login` 新增局部 try/catch
- 保留业务状态码
- 返回 `{ code, msg, data }`
- 写入 `logger.error('微信登录失败', ...)`
### 2. 全局错误包装顺序修正
- `routerUse(...)` 全局错误包装提前到路由注册前。
- 统一兼容 `err.statusCode` / `err.status`
### 3. auth 保存失败透传
新增 `saveAuthUserRecord(record)` 包装 `$app.save(record)`
- 失败时统一抛出 `保存认证用户失败`
- 附带 `originalMessage``originalData`
目的:
- 避免 PocketBase 默认 `Something went wrong while processing your request.` 吞掉具体原因。
---
## 五、数据库索引策略修复
### 1. users_phone 索引调整
原设计:
- `users_phone` 唯一索引
问题:
- 新用户注册阶段手机号为空,多个空值会触发唯一约束冲突,导致注册失败。
现调整为:
- `users_phone` 普通索引
说明:
- 手机号唯一性改由业务逻辑在资料完善阶段校验。
- 允许多个未完善资料用户以空手机号存在。
---
## 六、接口契约同步结果
当前 active PocketBase hooks 契约如下:
- `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 /pb/api/platform/register`
- body 必填:`users_name``users_phone``password``passwordConfirm``users_picture`
- 自动生成 GUID 并写入统一身份字段 `openid`
- 写入 `users_idtype = ManagePlatform`
- 成功时返回统一结构:`code``msg``data`,并在顶层额外返回 `token`
### `POST /pb/api/platform/login`
- body 必填:`login_account``password`
- 仅允许 `users_idtype = ManagePlatform`
- 前端使用邮箱或手机号 + 密码提交
- 服务端先通过 PocketBase `auth-with-password` 校验身份,再由当前 hooks 进程签发正式 token
- 成功时返回统一结构:`code``msg``data`,并在顶层额外返回 `token`
其中:
### `POST /pb/api/wechat/login`
- body 必填:`users_wx_code`
- 自动以微信 code 换取微信侧 `openid` 并写入统一身份字段
- 若不存在 auth 用户则尝试创建 `tbl_auth_users` 记录
- 写入 `users_idtype = WeChat`
- 成功时返回统一结构:`code``msg``data`,并在顶层额外返回 `token`
### `POST /pb/api/wechat/profile`
-`Authorization`
- 基于当前 auth record 的 `openid` 定位用户
- 服务端用 `users_phone_code` 换取手机号后保存
### `POST /pb/api/system/refresh-token`
- body 可选:`users_wx_code`(允许为空)
- `Authorization` 可选:
- 若 token 仍有效:基于当前 auth record 续签
- 若 token 已过期:回退到微信 code 重签流程
- 若 token 已过期且未提供 `users_wx_code`,返回:`token已过期请上传users_wx_code`
- 返回统一结构:`code``msg``data`,并在顶层额外返回新 `token`
- 属于系统级通用认证能力,不限定为微信专属接口
### 字典管理接口
新增 `dictionary` 分类接口,统一要求平台管理用户访问:
- `POST /pb/api/dictionary/list`
- 支持按 `dict_name` 模糊搜索
- 返回字典全量信息,并将 `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`
- 新增字典,`system_dict_id` 自动生成,`dict_name` 唯一
- `POST /pb/api/dictionary/update`
-`original_dict_name` / `dict_name` 更新字典
- `POST /pb/api/dictionary/delete`
-`dict_name` 真删除字典
说明:
- `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 }]`
### 附件管理接口
新增 `attachment` 分类接口,统一要求平台管理用户访问:
- `POST /pb/api/attachment/list`
- 支持按 `attachments_id``attachments_filename` 模糊搜索
- 支持按 `attachments_status` 过滤
- 返回附件元数据以及 PocketBase 文件流链接 `attachments_url`
- `POST /pb/api/attachment/detail`
-`attachments_id` 查询单个附件
- 返回文件流链接与下载链接
- `POST /pb/api/attachment/upload`
- 使用 `multipart/form-data`
- 文件字段固定为 `attachments_link`
- 上传成功后自动生成 `attachments_id`
- 自动写入 `attachments_owner = 当前用户 openid`
- `POST /pb/api/attachment/delete`
-`attachments_id` 真删除附件
- 若该附件已被 `tbl_document.document_image``document_video``document_file` 中的任一附件列表引用,则拒绝删除
说明:
- `tbl_attachments.attachments_link` 为 PocketBase `file` 字段,保存实际文件本体。
- 对外查询时会额外补充:
- `attachments_url`
- `attachments_download_url`
### 文档管理接口
新增 `document` 分类接口,统一要求平台管理用户访问:
- `POST /pb/api/document/list`
- 支持按 `document_id``document_title``document_subtitle``document_summary``document_keywords` 模糊搜索
- 支持按 `document_status``document_type` 过滤
- 返回时会自动联查 `tbl_attachments`
- 额外补充:
- `document_image_urls`
- `document_video_urls`
- `document_file_urls`
- `document_image_attachments`
- `document_video_attachments`
- `document_file_attachments`
- `POST /pb/api/document/detail`
-`document_id` 查询单条文档
- 返回与附件表联动解析后的多文件流链接
- `POST /pb/api/document/create`
- 新增文档
- `document_id` 可不传,由服务端自动生成
- `document_title``document_type` 为必填;其余字段均允许为空
- `document_image``document_video``document_file` 支持传入多个已存在的 `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` 真删除文档
- 删除前会写入一条文档操作历史,类型为 `delete`
说明:
- `document_image``document_video``document_file` 当前保存的是多个 `attachments_id`,底层以 `|` 分隔文本持久化,不是 PocketBase 文件字段。
- 文档查询时通过 `attachments_id -> tbl_attachments` 反查实际文件,并返回可直接访问的数据流链接数组。
- `document_owner` 语义为“上传者 openid”。
### 文档操作历史接口
新增 `document-history` 分类接口,统一要求平台管理用户访问:
- `POST /pb/api/document-history/list`
- 不传 `document_id` 时返回全部文档历史
- 传入 `document_id` 时仅返回该文档历史
- 结果按创建时间倒序排列
说明:
- 操作历史表为 `tbl_document_operation_history`
- 当前由文档新增、修改、删除接口自动写入
- 主要字段为:
- `doh_document_id`
- `doh_operation_type`
- `doh_user_id`
- `doh_current_count`
- `doh_remark`
---
## 七、页面与运维辅助能力新增
### 1. PocketBase 页面
当前页面入口:
- `/pb/manage`
- `/pb/manage/login`
- `/pb/manage/dictionary-manage`
- `/pb/manage/document-manage`
页面能力:
- 首页支持跳转到子页面
- 字典管理页支持:
- Bearer Token 粘贴与本地保存
- `dict_name` 模糊搜索
- 指定字典查询
- 行内编辑基础字段
- 弹窗编辑枚举项
- 为每个枚举项单独上传图片,并保存对应 `attachments_id`
- 回显字典项图片缩略图与文件流链接
- 新增 / 删除字典
- 返回主页
- 文档管理页支持:
- 先上传附件到 `tbl_attachments`
- 再把返回的多个 `attachments_id` 写入 `tbl_document.document_image` / `document_video` / `document_file`
- 图片、视频、文件都支持多选上传
- 新增文档
- 编辑已有文档并回显多图片、多视频
- 从文档中移除附件并在保存后删除对应附件记录
- 查询文档列表
- 直接展示 PocketBase 文件流链接
- 删除文档
说明:
- 原页面 `page-b.js` 已替换为 `document-manage.js`
- 页面实际走的接口链路为:
- `/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 /pb/api/system/health` 新增:
- `data.version`
用途:
- 通过修改 `APP_VERSION` 判断 hooks 是否已成功部署并生效
配置来源:
- 进程环境变量 `APP_VERSION`
-`runtime.js`
---
## 八、OpenAPI 与 Apifox 调试策略调整
### 1. 统一返回结构
所有对外接口统一返回:
- `code`
- `msg`
- `data`
认证成功类接口额外返回:
- `token`
不再返回以下顶层字段:
- `record`
- `meta`
### 2. 鉴权文档策略
OpenAPI 文档中已取消 `bearerAuth` 鉴权组件与接口级重复 `Authorization` 参数。
统一约定:
- 在 Apifox 环境中配置全局 Header`Authorization: Bearer {{token}}`
- 不再依赖文档中的 Bearer 组件自动注入
此举目的是:
- 避免接口页重复出现局部 `Authorization`
- 统一依赖环境变量完成鉴权注入
---
## 九、当前已知边界
1. `tbl_auth_users` 为 PocketBase `auth` 集合,因此仍受 PocketBase auth 内置规则影响。
2. 自定义字段“除 openid 外均可空”已在脚本层按目标放宽,但 auth 集合结构更新仍可能触发 PocketBase 服务端限制。
3. 若线上仍返回 PocketBase 默认 400需要确保最新 hooks 已部署并重启生效。
4. 平台登录通过回源 PocketBase REST 完成密码校验,因此 `POCKETBASE_API_URL` 必须配置为 PocketBase 进程/容器内部可达地址,不应使用外部 HTTPS 域名。
5. Apifox 环境中需自行维护全局 Header`Authorization: Bearer {{token}}`,否则鉴权接口不会自动携带 token。
---
## 十、归档建议
部署时至少同步以下文件:
- `pocket-base/bai-api-main.pb.js`
- `pocket-base/bai-web-main.pb.js`
- `pocket-base/bai_api_pb_hooks/`
- `pocket-base/bai_web_pb_hooks/`
- `pocket-base/spec/openapi.yaml`
- `script/pocketbase.js`
并在 PocketBase 环境中执行 schema 同步后重启服务,再进行接口验证。
建议归档后的发布核验顺序:
1. `POST /pb/api/system/health`:确认 `data.version`
2. `POST /pb/api/platform/login`:确认返回统一结构与顶层 `token`
3. `POST /pb/api/dictionary/list`:确认鉴权与字典接口可用
---
## 十一、归档状态
- 已将本轮字典管理、PocketBase 页面、统一响应、平台登录兼容修复、健康检查版本探针、OpenAPI/Apifox 鉴权策略调整并入本变更记录。
- 本记录可作为当前 PocketBase hooks 阶段性归档基线继续维护。

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@ info:
本文档面向小程序端直接使用 PocketBase JS SDK / REST API 访问 `tbl_company`。
本文档统一以 PocketBase 原生记录主键 `id` 作为唯一识别键。
`company_id` 保留为普通业务字段,可用于展示、筛选和业务关联,但不再作为 CRUD 的唯一键。
当前线上 `tbl_company` 还包含 `company_owner_openid` 字段,用于保存公司所有者 openid并带普通索引。
同时新增了国家、省、市、区的名称与编码字段,便于前端直接按行政区划存取。
license:
name: Proprietary
identifier: LicenseRef-Proprietary
@@ -29,7 +31,8 @@ paths:
支持三种常见模式:
1. 全表查询:不传 `filter`
2. 精确查询:`filter=id="q1w2e3r4t5y6u7i"`
3. 模糊查询:`filter=(company_name~"华住" || company_usci~"9131" || company_entity~"张三")`
3. 模糊查询:`filter=(company_name~"华住" || company_usci~"9131" || company_entity~"张三")`
4. 按 `company_id` 查询单条:`filter=company_id="WX-COMPANY-10001"&perPage=1&page=1`。
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/PerPage'
@@ -64,12 +67,18 @@ paths:
company_entity: 张三
company_usci: '91310000123456789A'
company_nationality: 中国
company_nationality_code: CN
company_province: 上海
company_province_code: '310000'
company_city: 上海
company_city_code: '310100'
company_district: 浦东新区
company_district_code: '310115'
company_postalcode: '200000'
company_add: 上海市浦东新区XX路1号
company_status: 有效
company_level: A
company_owner_openid: wx-openid-owner-001
company_remark: ''
exact:
summary: 按 id 精确查询
@@ -90,12 +99,18 @@ paths:
company_entity: 张三
company_usci: '91310000123456789A'
company_nationality: 中国
company_nationality_code: CN
company_province: 上海
company_province_code: '310000'
company_city: 上海
company_city_code: '310100'
company_district: 浦东新区
company_district_code: '310115'
company_postalcode: '200000'
company_add: 上海市浦东新区XX路1号
company_status: 有效
company_level: A
company_owner_openid: wx-openid-owner-001
company_remark: ''
'400':
description: 过滤表达式或查询参数不合法
@@ -115,7 +130,8 @@ paths:
summary: 新增公司
description: >-
创建一条 `tbl_company` 记录。当前文档以 `id` 作为记录唯一识别键,
新建成功后由 PocketBase 自动生成 `id`根据当前项目建表脚本,`company_id` 仍是必填业务字段,但不再作为 CRUD 唯一键。
新建成功后由 PocketBase 自动生成 `id``company_id` 也由数据库自动生成,
客户端创建时不需要传入,但仍可作为后续业务查询字段。
requestBody:
required: true
content:
@@ -125,18 +141,23 @@ paths:
examples:
default:
value:
company_id: C10001
company_name: 宝镜科技
company_type: 渠道商
company_entity: 张三
company_usci: '91310000123456789A'
company_nationality: 中国
company_nationality_code: CN
company_province: 上海
company_province_code: '310000'
company_city: 上海
company_city_code: '310100'
company_district: 浦东新区
company_district_code: '310115'
company_postalcode: '200000'
company_add: 上海市浦东新区XX路1号
company_status: 有效
company_level: A
company_owner_openid: wx-openid-owner-001
company_remark: 首次创建
responses:
'200':
@@ -198,6 +219,8 @@ paths:
summary: 按 PocketBase 记录 id 更新公司
description: >-
这是 PocketBase 原生更新接口,路径参数统一使用记录主键 `id`。
如果业务侧只有 `company_id`,标准流程是先调用 list 接口
`filter=company_id="..."&perPage=1&page=1` 查出对应记录,再用返回的 `id` 调用本接口。
parameters:
- $ref: '#/components/parameters/RecordId'
- $ref: '#/components/parameters/Fields'
@@ -213,6 +236,7 @@ paths:
company_name: 宝镜科技(更新)
company_status: 有效
company_level: S
company_owner_openid: wx-openid-owner-002
company_remark: 已更新基础资料
responses:
'200':
@@ -349,12 +373,27 @@ components:
company_nationality:
type: string
description: 国家
company_nationality_code:
type: string
description: 国家编码
company_province:
type: string
description: 省份
company_province_code:
type: string
description: 省份编码
company_city:
type: string
description: 城市
company_city_code:
type: string
description: 城市编码
company_district:
type: string
description: 区/县
company_district_code:
type: string
description: 区/县编码
company_postalcode:
type: string
description: 邮编
@@ -367,44 +406,132 @@ components:
company_level:
type: string
description: 公司等级
company_owner_openid:
type: string
description: 公司所有者 openid
company_remark:
type: string
description: 备注
CompanyCreateRequest:
allOf:
- $ref: '#/components/schemas/CompanyBase'
- type: object
required: [company_id]
type: object
description: 创建时不需要传 `company_id`,由数据库自动生成。
properties:
company_name:
description: "公司名称"
type: string
company_type:
description: "公司类型"
type: string
company_entity:
description: "公司法人"
type: string
company_usci:
description: "统一社会信用代码"
type: string
company_nationality:
description: "国家名称"
type: string
company_nationality_code:
description: "国家编码"
type: string
company_province:
description: "省份名称"
type: string
company_province_code:
description: "省份编码"
type: string
company_city:
description: "城市名称"
type: string
company_city_code:
description: "城市编码"
type: string
company_district:
description: "区 / 县名称"
type: string
company_district_code:
description: "区 / 县编码"
type: string
company_postalcode:
description: "邮编"
type: string
company_add:
description: "地址"
type: string
company_status:
description: "公司状态"
type: string
company_level:
description: "公司等级"
type: string
company_owner_openid:
description: "公司所有者 openid"
type: string
company_remark:
description: "备注"
type: string
additionalProperties: false
CompanyUpdateRequest:
type: object
description: >-
更新时可只传需要修改的字段;记录定位统一依赖路径参数 `id`。
properties:
company_id:
description: "所属公司业务 ID"
type: string
company_name:
description: "公司名称"
type: string
company_type:
description: "公司类型"
type: string
company_entity:
description: "公司法人"
type: string
company_usci:
description: "统一社会信用代码"
type: string
company_nationality:
description: "国家名称"
type: string
company_nationality_code:
description: "国家编码"
type: string
company_province:
description: "省份名称"
type: string
company_province_code:
description: "省份编码"
type: string
company_city:
description: "城市名称"
type: string
company_city_code:
description: "城市编码"
type: string
company_district:
description: "区 / 县名称"
type: string
company_district_code:
description: "区 / 县编码"
type: string
company_postalcode:
description: "邮编"
type: string
company_add:
description: "地址"
type: string
company_status:
description: "公司状态"
type: string
company_level:
description: "公司等级"
type: string
company_owner_openid:
description: "公司所有者 openid"
type: string
company_remark:
description: "备注"
type: string
CompanyRecord:
allOf:
@@ -418,8 +545,10 @@ components:
collectionName:
type: string
created:
description: "记录创建时间"
type: string
updated:
description: "记录更新时间"
type: string
- $ref: '#/components/schemas/CompanyBase'
CompanyListResponse:
@@ -445,5 +574,6 @@ components:
message:
type: string
data:
description: "业务响应数据"
type: object
additionalProperties: true

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff