feat: 添加产品列表集合初始化脚本,包含字段定义、索引创建及校验逻辑

This commit is contained in:
2026-04-01 11:59:58 +08:00
parent 7a21b3e5db
commit c9c4b4aaf8
20 changed files with 2806 additions and 1 deletions

View File

@@ -0,0 +1,57 @@
# pb_tbl_product_list
> 来源:产品表字段草图(用户提供)与现有 PocketBase 表设计规范
> 类型:`base`
> 读写规则公开可读PocketBase `listRule = ""`、`viewRule = ""`);新增 / 修改 / 删除仅 `ManagePlatform` / 管理角色允许
## 表用途
用于存储产品主数据,承载型号、分类、方案、通讯、供电、标签、价格等信息,并为前端产品列表检索与筛选提供统一数据源。
## 字段清单
| 字段名 | 类型 | 必填 | 说明 |
| :--- | :--- | :---: | :--- |
| `id` | `text` | 是 | PocketBase 记录主键 |
| `prod_list_id` | `text` | 是 | 产品列表业务 ID唯一标识 |
| `prod_list_name` | `text` | 是 | 产品名称 |
| `prod_list_modelnumber` | `text` | 否 | 产品型号 |
| `prod_list_icon` | `text` | 否 | 产品图标,保存 `tbl_attachments.attachments_id` |
| `prod_list_description` | `text` | 否 | 产品说明editor 内容,建议保存 Markdown 或已净化 HTML |
| `prod_list_feature` | `text` | 否 | 产品特色 |
| `prod_list_parameters` | `json` | 否 | 产品参数JSON 对象/数组) |
| `prod_list_plantype` | `text` | 否 | 产品方案 |
| `prod_list_category` | `text` | 是 | 产品分类(必填,单选) |
| `prod_list_sort` | `number` | 否 | 排序值(同分类内按升序) |
| `prod_list_comm_type` | `text` | 否 | 通讯类型 |
| `prod_list_series` | `text` | 否 | 产品系列 |
| `prod_list_power_supply` | `text` | 否 | 供电方式 |
| `prod_list_tags` | `text` | 否 | 产品标签(辅助检索,以 `|` 聚合) |
| `prod_list_status` | `text` | 否 | 产品状态(有效 / 过期 / 主推等) |
| `prod_list_basic_price` | `number` | 否 | 基础价格 |
| `prod_list_remark` | `text` | 否 | 备注 |
## 索引
| 索引名 | 类型 | 说明 |
| :--- | :--- | :--- |
| `idx_tbl_product_list_prod_list_id` | `UNIQUE INDEX` | 保证 `prod_list_id` 唯一 |
| `idx_tbl_product_list_prod_list_name` | `INDEX` | 加速按产品名称检索 |
| `idx_tbl_product_list_prod_list_modelnumber` | `INDEX` | 加速按产品型号检索 |
| `idx_tbl_product_list_prod_list_status` | `INDEX` | 加速按产品状态过滤 |
| `idx_tbl_product_list_prod_list_category` | `INDEX` | 加速按产品分类过滤 |
| `idx_tbl_product_list_prod_list_sort` | `INDEX` | 加速按排序值检索 |
| `idx_tbl_product_list_category_sort` | `INDEX` | 加速分类内排序计算 |
| `idx_tbl_product_list_prod_list_series` | `INDEX` | 加速按产品系列过滤 |
| `idx_tbl_product_list_prod_list_tags` | `INDEX` | 加速标签检索(模糊筛选场景) |
## 补充约定
- `prod_list_icon` 仅保存附件业务 ID真实文件统一在 `tbl_attachments`
- 当前预构建脚本中已将 `listRule``viewRule` 设置为空字符串(`""`),对应 PocketBase 的“任何人可查看”。
- `prod_list_parameters` 使用 PocketBase `json` 字段,写入时应直接提交对象/数组,不建议再二次字符串化。
- `prod_list_description` 作为 editor 内容字段,建议统一为 Markdown 或净化后的 HTML若允许 HTML需在写入前做 XSS 白名单过滤。
- 前端渲染 `prod_list_description` 时应保持和存储格式一致Markdown 渲染器或受控 HTML 渲染),避免直接注入未净化内容。
- `prod_list_basic_price` 使用 `number`,如需货币精度策略建议后续统一为“分”或固定小数位。
- PocketBase 系统字段 `created``updated` 仍然存在,只是不在 collection 字段清单里单独声明。
- 本文档为预构建结构说明,尚未执行线上建表。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-31

View File

@@ -0,0 +1,42 @@
## Overview
本次是“预构建变更”,目标是在不改动线上数据的前提下,先形成 `tbl_product_list` 的标准资料与可执行脚本。设计基线来自你给出的字段图,并对齐现有 `tbl_document``tbl_system_dict` 等表的命名与索引风格。
## Data Model Design
### Collection
- 名称:`tbl_product_list`
- 类型:`base`
- 规则默认公开可读list/view写操作由管理角色控制create/update/delete
### Fields
- `prod_list_id`:业务 ID唯一
- `prod_list_name`:产品名称
- `prod_list_modelnumber`:产品型号
- `prod_list_icon`:产品图标,对应 `tbl_attachments.attachments_id`
- `prod_list_description`:产品说明(富文本/编辑器内容)
- `prod_list_feature`:产品特色
- `prod_list_parameters`产品参数JSON 字符串
- `prod_list_plantype`:产品方案索引
- `prod_list_category`:功能类别索引
- `prod_list_comm_type`:通讯类型索引
- `prod_list_series`:产品系列索引
- `prod_list_power_supply`:供电方式索引
- `prod_list_tags`:产品标签(辅助检索)
- `prod_list_status`:产品状态(有效/过期/主推等)
- `prod_list_basic_price`:基础价格
- `prod_list_remark`:备注
## Index Strategy
- 唯一索引:`prod_list_id`
- 普通索引:`prod_list_name``prod_list_modelnumber``prod_list_status``prod_list_category``prod_list_series``prod_list_tags`
## Script Strategy
- 认证方式:`POCKETBASE_AUTH_TOKEN`(与 `script/pocketbase.documents.js` 一致)
- 执行行为:幂等 create-or-update
- 校验行为:回读字段类型/必填状态与索引是否齐全
- 风险控制:不在脚本中自动执行,仅通过 npm script 暴露手动入口

View File

@@ -0,0 +1,27 @@
## Why
当前 `docs/` 中尚未覆盖产品列表实体的 PocketBase 结构定义,且你已提供了 `tbl_product_list` 字段草图。如果不先把目标结构文档化并预置建表脚本,后续接口开发会出现前后端字段口径不一致、索引策略不统一的问题。
## What Changes
- 新增 `docs/pb_tbl_product_list.md`,按现有数据库文档规范沉淀字段、索引、读写规则与补充约定。
- 新增 `script/pocketbase.product-list.js`,提供 `tbl_product_list` 的幂等建表/更新与结构校验逻辑。
- 新增 npm script 入口 `init:product-list`,用于后续按需执行(本次不执行)。
- 新增 OpenSpec 变更,要求产品表文档必须纳入统一的 per-collection 文档体系。
## Capabilities
### Modified Capabilities
- `pocketbase-schema-docs`
## Implementation Strategy
- 继续沿用现有 Node.js + npm 工具链与 PocketBase JS SDK`pocketbase@^0.26.8`)。
- 建表脚本复用项目已验证的 `fields` payload 写法(避免 `schema` 兼容性问题)。
- 索引采用“业务唯一键 + 高频筛选字段普通索引”的策略,以兼容老库查询习惯。
## Impact
- 受影响目录:`docs/``script/``openspec/`
- 本次只做预构建文件,不触发线上建表或数据迁移

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Product list schema docs SHALL be present before rollout
When introducing `tbl_product_list`, the project SHALL provide a dedicated per-collection database document before executing any production schema change.
#### Scenario: Product list collection is planned
- **WHEN** the team plans to create `tbl_product_list`
- **THEN** `docs/pb_tbl_product_list.md` SHALL exist and describe fields, indexes, access rules, and supplementary constraints
#### Scenario: Product list doc follows unified format
- **WHEN** a reader opens the product list schema document
- **THEN** the document SHALL follow the same standard sections used by existing `pb_tbl_*` docs

View File

@@ -0,0 +1,17 @@
## 1. 文档预构建
- [x] 1.1 依据字段草图整理 `tbl_product_list` 字段清单。
- [x] 1.2 以统一模板新增 `docs/pb_tbl_product_list.md`
- [x] 1.3 定义索引与补充约定附件引用、JSON 字段、状态字段)。
## 2. 脚本预构建
- [x] 2.1 新增 `script/pocketbase.product-list.js`
- [x] 2.2 实现幂等创建/更新与结构校验。
- [x] 2.3 在 `script/package.json` 增加 npm script 入口。
## 3. OpenSpec 记录
- [x] 3.1 新建 change 提案并补充设计说明。
- [x] 3.2 为 `pocketbase-schema-docs` 增加 `tbl_product_list` 文档覆盖要求。
- [ ] 3.3 待你确认后再执行实际建表与验收。

View File

@@ -36,6 +36,11 @@ require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/detail.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/create.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/update.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/delete.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/product/list.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/product/detail.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/product/create.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/product/update.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/product/delete.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document-history/list.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/context.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/role-save.js`)

View File

@@ -1,5 +1,6 @@
require(`${__hooks}/bai_web_pb_hooks/pages/index.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/login.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/document-manage.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/product-manage.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/dictionary-manage.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/sdk-permission-manage.js`)

View File

@@ -0,0 +1,25 @@
routerAdd('POST', '/api/product/create', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const productService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/productService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
guards.requireJson(e)
const authState = guards.requireManagePlatformUser(e)
guards.duplicateGuard(e)
const payload = guards.validateProductMutationBody(e, false)
const data = productService.createProduct(authState.openid, payload)
return success(e, '新增产品成功', data)
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('新增产品失败', {
status: status,
errMsg: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status)
}
})

View File

@@ -0,0 +1,25 @@
routerAdd('POST', '/api/product/delete', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const productService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/productService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
guards.requireJson(e)
const authState = guards.requireManagePlatformUser(e)
guards.duplicateGuard(e)
const payload = guards.validateProductDeleteBody(e)
const data = productService.deleteProduct(authState.openid, payload.prod_list_id)
return success(e, '删除产品成功', data)
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('删除产品失败', {
status: status,
errMsg: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status)
}
})

View File

@@ -0,0 +1,24 @@
routerAdd('POST', '/api/product/detail', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const productService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/productService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
guards.requireJson(e)
guards.requireManagePlatformUser(e)
const payload = guards.validateProductDetailBody(e)
const data = productService.getProductDetail(payload.prod_list_id)
return success(e, '查询产品详情成功', data)
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('查询产品详情失败', {
status: status,
errMsg: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status)
}
})

View File

@@ -0,0 +1,26 @@
routerAdd('POST', '/api/product/list', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const productService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/productService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
guards.requireJson(e)
guards.requireManagePlatformUser(e)
const payload = guards.validateProductListBody(e)
const data = productService.listProducts(payload)
return success(e, '查询产品列表成功', {
items: data,
})
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('查询产品列表失败', {
status: status,
errMsg: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status)
}
})

View File

@@ -0,0 +1,25 @@
routerAdd('POST', '/api/product/update', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const productService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/productService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success, fail } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
guards.requireJson(e)
const authState = guards.requireManagePlatformUser(e)
guards.duplicateGuard(e)
const payload = guards.validateProductMutationBody(e, true)
const data = productService.updateProduct(authState.openid, payload)
return success(e, '修改产品成功', data)
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('修改产品失败', {
status: status,
errMsg: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return fail(e, (err && err.message) || '操作失败', (err && err.data) || {}, status)
}
})

View File

@@ -347,6 +347,92 @@ function validateDocumentDeleteBody(e) {
return validateDocumentDetailBody(e)
}
function normalizeProductParameters(value) {
if (value === null || typeof value === 'undefined' || value === '') {
return {}
}
if (typeof value !== 'object' || Array.isArray(value)) {
throw createAppError(400, 'prod_list_parameters 必须为对象')
}
const result = {}
const keys = Object.keys(value)
for (let i = 0; i < keys.length; i += 1) {
const key = String(keys[i] || '').trim()
if (!key) {
continue
}
const current = value[keys[i]]
result[key] = current === null || typeof current === 'undefined' ? '' : String(current)
}
return result
}
function validateProductListBody(e) {
const payload = parseBody(e)
return {
keyword: payload.keyword || '',
status: payload.status || '',
prod_list_category: payload.prod_list_category || '',
}
}
function validateProductDetailBody(e) {
const payload = parseBody(e)
if (!payload.prod_list_id) {
throw createAppError(400, 'prod_list_id 为必填项')
}
return {
prod_list_id: String(payload.prod_list_id || '').trim(),
}
}
function validateProductMutationBody(e, isUpdate) {
const payload = parseBody(e)
if (isUpdate && !payload.prod_list_id) {
throw createAppError(400, 'prod_list_id 为必填项')
}
if (!payload.prod_list_name) {
throw createAppError(400, 'prod_list_name 为必填项')
}
if (!payload.prod_list_category) {
throw createAppError(400, 'prod_list_category 为必填项')
}
return {
prod_list_id: payload.prod_list_id || '',
prod_list_name: payload.prod_list_name || '',
prod_list_modelnumber: payload.prod_list_modelnumber || '',
prod_list_icon: payload.prod_list_icon || '',
prod_list_description: payload.prod_list_description || '',
prod_list_feature: payload.prod_list_feature || '',
prod_list_parameters: normalizeProductParameters(payload.prod_list_parameters),
prod_list_plantype: payload.prod_list_plantype || '',
prod_list_category: payload.prod_list_category || '',
prod_list_sort: typeof payload.prod_list_sort === 'undefined' ? 0 : payload.prod_list_sort,
prod_list_comm_type: payload.prod_list_comm_type || '',
prod_list_series: payload.prod_list_series || '',
prod_list_power_supply: payload.prod_list_power_supply || '',
prod_list_tags: payload.prod_list_tags || '',
prod_list_status: payload.prod_list_status || '',
prod_list_basic_price: typeof payload.prod_list_basic_price === 'undefined' ? '' : payload.prod_list_basic_price,
prod_list_remark: payload.prod_list_remark || '',
}
}
function validateProductDeleteBody(e) {
return validateProductDetailBody(e)
}
function validateDocumentHistoryListBody(e) {
const payload = parseBody(e)
@@ -518,6 +604,10 @@ module.exports = {
validateDocumentDetailBody,
validateDocumentMutationBody,
validateDocumentDeleteBody,
validateProductListBody,
validateProductDetailBody,
validateProductMutationBody,
validateProductDeleteBody,
validateDocumentHistoryListBody,
validateSdkPermissionContextBody,
validateSdkPermissionRoleBody,

View File

@@ -0,0 +1,474 @@
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`)
function buildBusinessId(prefix) {
return prefix + '-' + new Date().getTime() + '-' + $security.randomString(6)
}
function normalizeText(value) {
return String(value || '').replace(/^\s+|\s+$/g, '')
}
function normalizeOptionalNumberValue(value, fieldName) {
if (value === '' || value === null || typeof value === 'undefined') {
return null
}
const num = Number(value)
if (!Number.isFinite(num)) {
throw createAppError(400, fieldName + ' 必须为数字')
}
return num
}
function normalizeSortValue(value) {
if (value === '' || value === null || typeof value === 'undefined') {
return 0
}
const num = Number(value)
if (!Number.isFinite(num)) {
throw createAppError(400, 'prod_list_sort 必须为数字')
}
return Math.floor(num)
}
function normalizePipeValues(value) {
return String(value || '')
.split('|')
.map(function (item) {
return normalizeText(item)
})
.filter(function (item) {
return !!item
})
}
function joinUniquePipeValues(value) {
const source = Array.isArray(value) ? value : normalizePipeValues(value)
const result = []
for (let i = 0; i < source.length; i += 1) {
const current = normalizeText(source[i])
if (!current || result.indexOf(current) !== -1) {
continue
}
result.push(current)
}
return result.join('|')
}
function normalizeRequiredCategory(value) {
const values = normalizePipeValues(value)
if (!values.length) {
throw createAppError(400, 'prod_list_category 为必填项')
}
return values[0]
}
function buildCategoryRankMap(records) {
const grouped = {}
for (let i = 0; i < records.length; i += 1) {
const category = normalizeText(records[i].prod_list_category)
if (!category) {
continue
}
if (!grouped[category]) {
grouped[category] = []
}
grouped[category].push(records[i])
}
const rankMap = {}
const categories = Object.keys(grouped)
for (let i = 0; i < categories.length; i += 1) {
const category = categories[i]
const items = grouped[category]
items.sort(function (a, b) {
const sortDiff = Number(a.prod_list_sort || 0) - Number(b.prod_list_sort || 0)
if (sortDiff !== 0) {
return sortDiff
}
const updateDiff = String(b.updated || '').localeCompare(String(a.updated || ''))
if (updateDiff !== 0) {
return updateDiff
}
return String(a.prod_list_id || '').localeCompare(String(b.prod_list_id || ''))
})
for (let j = 0; j < items.length; j += 1) {
rankMap[items[j].prod_list_id] = j + 1
}
}
return rankMap
}
function normalizeParameters(value) {
if (value === null || typeof value === 'undefined' || value === '') {
return {}
}
if (typeof value !== 'object' || Array.isArray(value)) {
throw createAppError(400, 'prod_list_parameters 必须是对象')
}
const result = {}
const keys = Object.keys(value)
for (let i = 0; i < keys.length; i += 1) {
const key = normalizeText(keys[i])
if (!key) {
continue
}
const current = value[keys[i]]
if (current === null || typeof current === 'undefined') {
result[key] = ''
continue
}
result[key] = String(current)
}
return result
}
function normalizeParametersForOutput(value) {
if (value === null || typeof value === 'undefined' || value === '') {
return {}
}
let source = value
if (typeof source === 'string') {
try {
source = JSON.parse(source)
} catch (_error) {
const raw = normalizeText(source)
if (raw.indexOf('map[') === 0 && raw.endsWith(']')) {
const body = raw.slice(4, -1).trim()
if (!body) {
return {}
}
const result = {}
const pairs = body.split(/\s+/)
for (let i = 0; i < pairs.length; i += 1) {
const pair = pairs[i]
const separatorIndex = pair.indexOf(':')
if (separatorIndex <= 0) {
continue
}
const key = normalizeText(pair.slice(0, separatorIndex))
if (!key) {
continue
}
const val = pair.slice(separatorIndex + 1)
result[key] = val === null || typeof val === 'undefined' ? '' : String(val)
}
return result
}
return {}
}
}
if (Array.isArray(source)) {
const mapped = {}
for (let i = 0; i < source.length; i += 1) {
const item = source[i] && typeof source[i] === 'object' ? source[i] : null
if (!item) {
continue
}
const key = normalizeText(item.key)
if (!key) {
continue
}
mapped[key] = item.value === null || typeof item.value === 'undefined' ? '' : String(item.value)
}
return mapped
}
if (typeof source !== 'object') {
return {}
}
// Some PocketBase/Goja map-like values are not directly enumerable; roundtrip to plain object.
try {
source = JSON.parse(JSON.stringify(source))
} catch (_error) {}
const result = {}
const keys = Object.keys(source)
for (let i = 0; i < keys.length; i += 1) {
const key = normalizeText(keys[i])
if (!key) {
continue
}
const current = source[keys[i]]
result[key] = current === null || typeof current === 'undefined' ? '' : String(current)
}
return result
}
function ensureAttachmentExists(attachmentId, fieldName) {
const value = normalizeText(attachmentId)
if (!value) {
return
}
try {
documentService.getAttachmentDetail(value)
} catch (_err) {
throw createAppError(400, fieldName + ' 对应附件不存在:' + value)
}
}
function findProductRecordByBusinessId(productId) {
const records = $app.findRecordsByFilter('tbl_product_list', 'prod_list_id = {:productId}', '', 1, 0, {
productId: productId,
})
return records.length ? records[0] : null
}
function exportProductRecord(record, extra) {
const iconId = record.getString('prod_list_icon')
let iconAttachment = null
if (iconId) {
try {
iconAttachment = documentService.getAttachmentDetail(iconId)
} catch (_error) {
iconAttachment = null
}
}
const parametersText = normalizeText(record.getString('prod_list_parameters'))
const parametersFromText = parametersText ? normalizeParametersForOutput(parametersText) : {}
const parametersRaw = record.get('prod_list_parameters')
const parametersFromRaw = normalizeParametersForOutput(parametersRaw)
const parameters = Object.keys(parametersFromText).length ? parametersFromText : parametersFromRaw
return {
pb_id: record.id,
prod_list_id: record.getString('prod_list_id'),
prod_list_name: record.getString('prod_list_name'),
prod_list_modelnumber: record.getString('prod_list_modelnumber'),
prod_list_icon: iconId,
prod_list_icon_attachment: iconAttachment,
prod_list_icon_url: iconAttachment ? iconAttachment.attachments_url : '',
prod_list_description: record.getString('prod_list_description'),
prod_list_feature: record.getString('prod_list_feature'),
prod_list_parameters: parameters,
prod_list_plantype: record.getString('prod_list_plantype'),
prod_list_category: record.getString('prod_list_category'),
prod_list_sort: Number(record.get('prod_list_sort') || 0),
prod_list_category_rank: extra && extra.categoryRank ? Number(extra.categoryRank) : 0,
prod_list_comm_type: record.getString('prod_list_comm_type'),
prod_list_series: record.getString('prod_list_series'),
prod_list_power_supply: record.getString('prod_list_power_supply'),
prod_list_tags: record.getString('prod_list_tags'),
prod_list_basic_price: record.get('prod_list_basic_price'),
prod_list_status: record.getString('prod_list_status'),
prod_list_remark: record.getString('prod_list_remark'),
created: String(record.created || ''),
updated: String(record.updated || ''),
}
}
function listProducts(payload) {
const allRecords = $app.findRecordsByFilter('tbl_product_list', '', '', 500, 0)
const keyword = normalizeText(payload.keyword).toLowerCase()
const status = normalizeText(payload.status)
const category = normalizeText(payload.prod_list_category)
const result = []
const allItems = []
for (let i = 0; i < allRecords.length; i += 1) {
allItems.push(exportProductRecord(allRecords[i]))
}
const rankMap = buildCategoryRankMap(allItems)
for (let i = 0; i < allItems.length; i += 1) {
const source = allItems[i]
const item = Object.assign({}, source, {
prod_list_category_rank: rankMap[source.prod_list_id] || 0,
})
const matchedKeyword = !keyword
|| item.prod_list_id.toLowerCase().indexOf(keyword) !== -1
|| item.prod_list_name.toLowerCase().indexOf(keyword) !== -1
|| item.prod_list_modelnumber.toLowerCase().indexOf(keyword) !== -1
|| item.prod_list_tags.toLowerCase().indexOf(keyword) !== -1
const matchedStatus = !status || item.prod_list_status === status
const matchedCategory = !category || item.prod_list_category === category
if (matchedKeyword && matchedStatus && matchedCategory) {
result.push(item)
}
}
result.sort(function (a, b) {
return String(b.updated || '').localeCompare(String(a.updated || ''))
})
return result
}
function getProductDetail(productId) {
const record = findProductRecordByBusinessId(productId)
if (!record) {
throw createAppError(404, '未找到对应产品')
}
const allRecords = $app.findRecordsByFilter('tbl_product_list', '', '', 500, 0)
const allItems = []
for (let i = 0; i < allRecords.length; i += 1) {
allItems.push(exportProductRecord(allRecords[i]))
}
const rankMap = buildCategoryRankMap(allItems)
return exportProductRecord(record, {
categoryRank: rankMap[record.getString('prod_list_id')] || 0,
})
}
function createProduct(_userOpenid, payload) {
const targetProductId = normalizeText(payload.prod_list_id) || buildBusinessId('PROD')
const duplicated = findProductRecordByBusinessId(targetProductId)
if (duplicated) {
throw createAppError(400, 'prod_list_id 已存在')
}
ensureAttachmentExists(payload.prod_list_icon, 'prod_list_icon')
const collection = $app.findCollectionByNameOrId('tbl_product_list')
const record = new Record(collection)
record.set('prod_list_id', targetProductId)
record.set('prod_list_name', normalizeText(payload.prod_list_name))
record.set('prod_list_modelnumber', normalizeText(payload.prod_list_modelnumber))
record.set('prod_list_icon', normalizeText(payload.prod_list_icon))
record.set('prod_list_description', normalizeText(payload.prod_list_description))
record.set('prod_list_feature', normalizeText(payload.prod_list_feature))
record.set('prod_list_parameters', normalizeParameters(payload.prod_list_parameters))
record.set('prod_list_plantype', normalizeText(payload.prod_list_plantype))
record.set('prod_list_category', normalizeRequiredCategory(payload.prod_list_category))
record.set('prod_list_sort', normalizeSortValue(payload.prod_list_sort))
record.set('prod_list_comm_type', normalizeText(payload.prod_list_comm_type))
record.set('prod_list_series', normalizeText(payload.prod_list_series))
record.set('prod_list_power_supply', normalizeText(payload.prod_list_power_supply))
record.set('prod_list_tags', joinUniquePipeValues(payload.prod_list_tags))
record.set('prod_list_status', normalizeText(payload.prod_list_status))
record.set('prod_list_basic_price', normalizeOptionalNumberValue(payload.prod_list_basic_price, 'prod_list_basic_price'))
record.set('prod_list_remark', normalizeText(payload.prod_list_remark))
try {
$app.save(record)
} catch (err) {
throw createAppError(400, '新增产品失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
logger.info('产品创建成功', {
prod_list_id: record.getString('prod_list_id'),
})
const rankMap = buildCategoryRankMap(listProducts({}))
return exportProductRecord(record, {
categoryRank: rankMap[record.getString('prod_list_id')] || 0,
})
}
function updateProduct(_userOpenid, payload) {
const targetId = normalizeText(payload.prod_list_id)
if (!targetId) {
throw createAppError(400, 'prod_list_id 为必填项')
}
const record = findProductRecordByBusinessId(targetId)
if (!record) {
throw createAppError(404, '未找到待修改的产品')
}
ensureAttachmentExists(payload.prod_list_icon, 'prod_list_icon')
record.set('prod_list_name', normalizeText(payload.prod_list_name))
record.set('prod_list_modelnumber', normalizeText(payload.prod_list_modelnumber))
record.set('prod_list_icon', normalizeText(payload.prod_list_icon))
record.set('prod_list_description', normalizeText(payload.prod_list_description))
record.set('prod_list_feature', normalizeText(payload.prod_list_feature))
record.set('prod_list_parameters', normalizeParameters(payload.prod_list_parameters))
record.set('prod_list_plantype', normalizeText(payload.prod_list_plantype))
record.set('prod_list_category', normalizeRequiredCategory(payload.prod_list_category))
record.set('prod_list_sort', normalizeSortValue(payload.prod_list_sort))
record.set('prod_list_comm_type', normalizeText(payload.prod_list_comm_type))
record.set('prod_list_series', normalizeText(payload.prod_list_series))
record.set('prod_list_power_supply', normalizeText(payload.prod_list_power_supply))
record.set('prod_list_tags', joinUniquePipeValues(payload.prod_list_tags))
record.set('prod_list_status', normalizeText(payload.prod_list_status))
record.set('prod_list_basic_price', normalizeOptionalNumberValue(payload.prod_list_basic_price, 'prod_list_basic_price'))
record.set('prod_list_remark', normalizeText(payload.prod_list_remark))
try {
$app.save(record)
} catch (err) {
throw createAppError(400, '更新产品失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
logger.info('产品更新成功', {
prod_list_id: targetId,
})
const rankMap = buildCategoryRankMap(listProducts({}))
return exportProductRecord(record, {
categoryRank: rankMap[record.getString('prod_list_id')] || 0,
})
}
function deleteProduct(_userOpenid, productId) {
const targetId = normalizeText(productId)
if (!targetId) {
throw createAppError(400, 'prod_list_id 为必填项')
}
const record = findProductRecordByBusinessId(targetId)
if (!record) {
throw createAppError(404, '未找到待删除的产品')
}
try {
$app.delete(record)
} catch (err) {
throw createAppError(400, '删除产品失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
logger.info('产品删除成功', {
prod_list_id: targetId,
})
return {
prod_list_id: targetId,
}
}
module.exports = {
listProducts,
getProductDetail,
createProduct,
updateProduct,
deleteProduct,
}

View File

@@ -19,11 +19,14 @@ routerAdd('GET', '/manage', function (e) {
.wrap { max-width: 1440px; margin: 0 auto; padding: 24px 14px; }
.hero { background: #ffffff; border-radius: 20px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); padding: 22px; border: 1px solid #e5e7eb; }
h1 { margin: 0 0 14px; font-size: 30px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.card { background: #f8fafc; border: 1px solid #dbe3f0; border-radius: 16px; padding: 16px; text-align: center; }
.card h2 { margin: 0 0 8px; font-size: 19px; }
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 16px; border-radius: 12px; text-decoration: none; background: #2563eb; color: #fff; font-weight: 600; margin-top: 12px; }
.actions { margin-top: 14px; display: flex; justify-content: flex-start; }
@media (max-width: 960px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
@@ -39,6 +42,10 @@ routerAdd('GET', '/manage', function (e) {
<h2>文档管理</h2>
<a class="btn" href="/pb/manage/document-manage">进入文档管理</a>
</article>
<article class="card">
<h2>产品管理</h2>
<a class="btn" href="/pb/manage/product-manage">进入产品管理</a>
</article>
<article class="card">
<h2>SDK 权限管理</h2>
<a class="btn" href="/pb/manage/sdk-permission-manage">进入权限管理</a>

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,8 @@ tags:
description: 通过 PocketBase 原生 records API 访问 `tbl_company`
- name: 附件信息
description: 通过 PocketBase 原生 records API 访问 `tbl_attachments`
- name: 产品信息
description: 通过 PocketBase 原生 records API 访问 `tbl_product_list`
- name: 文档信息
description: 通过 PocketBase 原生 records API 访问 `tbl_document`
paths:
@@ -645,6 +647,119 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseNativeError'
/pb/api/collections/tbl_product_list/records:
get:
operationId: getPocketBaseProductListRecords
tags:
- 产品信息
summary: 根据产品分类精确筛选并按分类排序值升序返回产品列表
description: |
使用 PocketBase 原生 records list 接口查询 `tbl_product_list`。
当前接口约定:
- 条件:按 `prod_list_category` 精确匹配筛选
- 排序:按 `prod_list_sort` 从小到大排序
标准调用参数建议:
- `filter=prod_list_category="<产品分类>"`
- `sort=prod_list_sort`
注意:
- 这是 PocketBase 原生返回结构,不是 hooks 统一 `{ statusCode, errMsg, data }` 包装
- 若不传 `sort`,将由 PocketBase 默认排序策略决定返回顺序
parameters:
- name: filter
in: query
required: true
description: |
PocketBase 标准过滤表达式,当前要求按产品分类精确值筛选。
推荐写法:`prod_list_category="<产品分类>"`
schema:
type: string
example: prod_list_category="<产品分类>"
- name: page
in: query
required: false
description: 页码
schema:
type: integer
minimum: 1
default: 1
- name: perPage
in: query
required: false
description: 每页条数
schema:
type: integer
minimum: 1
default: 20
- name: sort
in: query
required: false
description: |
PocketBase 原生排序表达式。
当前要求使用:
- `prod_list_sort`:按分类排序值从小到大
schema:
type: string
example: prod_list_sort
responses:
'200':
description: 查询成功
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseProductListListResponse'
examples:
byCategoryAscSort:
value:
page: <页码>|<integer>
perPage: <每页条数>|<integer>
totalItems: <总记录数>|<integer>
totalPages: <总页数>|<integer>
items:
- id: <PocketBase记录主键>|<string>
collectionId: <集合ID>|<string>
collectionName: <集合名称>|<string>
created: <记录创建时间>|<string>
updated: <记录更新时间>|<string>
prod_list_id: <产品列表业务ID>|<string>
prod_list_name: <产品名称>|<string>
prod_list_modelnumber: <产品型号>|<string>
prod_list_icon: <产品图标附件ID>|<string>
prod_list_description: <产品说明>|<string>
prod_list_feature: <产品特色>|<string>
prod_list_parameters: <产品参数JSON>|<object>
prod_list_plantype: <产品方案>|<string>
prod_list_category: <产品分类>|<string>
prod_list_sort: <排序值>|<number>
prod_list_comm_type: <通讯类型>|<string>
prod_list_series: <产品系列>|<string>
prod_list_power_supply: <供电方式>|<string>
prod_list_tags: <产品标签>|<string>
prod_list_status: <产品状态>|<string>
prod_list_basic_price: <基础价格>|<number>
prod_list_remark: <备注>|<string>
'400':
description: 查询参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseNativeError'
'403':
description: 集合规则被锁定或服务端权限设置异常
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseNativeError'
'500':
description: 服务端错误
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseNativeError'
/pb/api/collections/tbl_document/records:
get:
operationId: getPocketBaseDocumentRecords
@@ -1485,6 +1600,173 @@ components:
attachments_ocr: OCR识别结果 | string
attachments_status: 附件状态 | string
attachments_remark: 备注 | string
PocketBaseProductListFields:
type: object
properties:
prod_list_id:
type: string
description: 产品列表业务 ID唯一标识
example: <产品列表业务ID>|<string>
prod_list_name:
type: string
description: 产品名称
example: <产品名称>|<string>
prod_list_modelnumber:
type: string
description: 产品型号
example: <产品型号>|<string>
prod_list_icon:
type: string
description: 产品图标附件 ID保存 `tbl_attachments.attachments_id`
example: <产品图标附件ID>|<string>
prod_list_description:
type: string
description: 产品说明
example: <产品说明>|<string>
prod_list_feature:
type: string
description: 产品特色
example: <产品特色>|<string>
prod_list_parameters:
type: object
additionalProperties: true
description: 产品参数JSON 对象/数组)
example:
key: <参数值>|<string>
prod_list_plantype:
type: string
description: 产品方案
example: <产品方案>|<string>
prod_list_category:
type: string
description: 产品分类(必填,单选)
example: <产品分类>|<string>
prod_list_sort:
type:
- number
- integer
description: 排序值(同分类内按升序)
example: <排序值>|<number>
prod_list_comm_type:
type: string
description: 通讯类型
example: <通讯类型>|<string>
prod_list_series:
type: string
description: 产品系列
example: <产品系列>|<string>
prod_list_power_supply:
type: string
description: 供电方式
example: <供电方式>|<string>
prod_list_tags:
type: string
description: 产品标签(辅助检索,以 `|` 聚合)
example: <产品标签>|<string>
prod_list_status:
type: string
description: 产品状态(有效 / 过期 / 主推等)
example: <产品状态>|<string>
prod_list_basic_price:
type:
- number
- integer
description: 基础价格
example: <基础价格>|<number>
prod_list_remark:
type: string
description: 备注
example: <备注>|<string>
PocketBaseProductListRecord:
allOf:
- $ref: '#/components/schemas/PocketBaseRecordBase'
- $ref: '#/components/schemas/PocketBaseProductListFields'
example:
id: <PocketBase记录主键>|<string>
collectionId: <集合ID>|<string>
collectionName: <集合名称>|<string>
created: <记录创建时间>|<string>
updated: <记录更新时间>|<string>
prod_list_id: <产品列表业务ID>|<string>
prod_list_name: <产品名称>|<string>
prod_list_modelnumber: <产品型号>|<string>
prod_list_icon: <产品图标附件ID>|<string>
prod_list_description: <产品说明>|<string>
prod_list_feature: <产品特色>|<string>
prod_list_parameters:
key: <参数值>|<string>
prod_list_plantype: <产品方案>|<string>
prod_list_category: <产品分类>|<string>
prod_list_sort: <排序值>|<number>
prod_list_comm_type: <通讯类型>|<string>
prod_list_series: <产品系列>|<string>
prod_list_power_supply: <供电方式>|<string>
prod_list_tags: <产品标签>|<string>
prod_list_status: <产品状态>|<string>
prod_list_basic_price: <基础价格>|<number>
prod_list_remark: <备注>|<string>
PocketBaseProductListListResponse:
type: object
required:
- page
- perPage
- totalItems
- totalPages
- items
properties:
page:
type:
- integer
- string
example: <页码>|<integer>
perPage:
type:
- integer
- string
example: <每页条数>|<integer>
totalItems:
type:
- integer
- string
example: <总记录数>|<integer>
totalPages:
type:
- integer
- string
example: <总页数>|<integer>
items:
type: array
items:
$ref: '#/components/schemas/PocketBaseProductListRecord'
example:
page: <页码>|<integer>
perPage: <每页条数>|<integer>
totalItems: <总记录数>|<integer>
totalPages: <总页数>|<integer>
items:
- id: <PocketBase记录主键>|<string>
collectionId: <集合ID>|<string>
collectionName: <集合名称>|<string>
created: <记录创建时间>|<string>
updated: <记录更新时间>|<string>
prod_list_id: <产品列表业务ID>|<string>
prod_list_name: <产品名称>|<string>
prod_list_modelnumber: <产品型号>|<string>
prod_list_icon: <产品图标附件ID>|<string>
prod_list_description: <产品说明>|<string>
prod_list_feature: <产品特色>|<string>
prod_list_parameters:
key: <参数值>|<string>
prod_list_plantype: <产品方案>|<string>
prod_list_category: <产品分类>|<string>
prod_list_sort: <排序值>|<number>
prod_list_comm_type: <通讯类型>|<string>
prod_list_series: <产品系列>|<string>
prod_list_power_supply: <供电方式>|<string>
prod_list_tags: <产品标签>|<string>
prod_list_status: <产品状态>|<string>
prod_list_basic_price: <基础价格>|<number>
prod_list_remark: <备注>|<string>
PocketBaseDocumentFields:
type: object
properties:

View File

@@ -7,6 +7,7 @@
"test": "echo \"Error: no test specified\" && exit 1",
"init:newpb": "node pocketbase.newpb.js",
"init:documents": "node pocketbase.documents.js",
"init:product-list": "node pocketbase.product-list.js",
"init:dictionary": "node pocketbase.dictionary.js",
"migrate:file-fields": "node pocketbase.file-fields-to-attachments.js",
"test:company-native-api": "node test-tbl-company-native-api.js",

View File

@@ -0,0 +1,257 @@
import { createRequire } from 'module';
import PocketBase from 'pocketbase';
const require = createRequire(import.meta.url);
let runtimeConfig = {};
try {
runtimeConfig = require('../pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js');
} catch (_error) {
runtimeConfig = {};
}
const PB_URL = (process.env.PB_URL || 'https://bai-api.blv-oa.com/pb').replace(/\/+$/, '');
const AUTH_TOKEN = process.env.POCKETBASE_AUTH_TOKEN || runtimeConfig.POCKETBASE_AUTH_TOKEN || '';
if (!AUTH_TOKEN) {
console.error('❌ 缺少 POCKETBASE_AUTH_TOKEN无法执行建表。');
process.exit(1);
}
const pb = new PocketBase(PB_URL);
const collections = [
{
name: 'tbl_product_list',
type: 'base',
// Empty rules in PocketBase mean public read access.
listRule: '',
viewRule: '',
createRule: '(@request.auth.users_idtype = "ManagePlatform" || @request.auth.usergroups_id = "ROLE-1774666070666-9dDrTB")',
updateRule: '(@request.auth.users_idtype = "ManagePlatform" || @request.auth.usergroups_id = "ROLE-1774666070666-9dDrTB")',
deleteRule: '(@request.auth.users_idtype = "ManagePlatform" || @request.auth.usergroups_id = "ROLE-1774666070666-9dDrTB")',
fields: [
{ name: 'prod_list_id', type: 'text', required: true },
{ name: 'prod_list_name', type: 'text', required: true },
{ name: 'prod_list_modelnumber', type: 'text' },
{ name: 'prod_list_icon', type: 'text' },
{ name: 'prod_list_description', type: 'text' },
{ name: 'prod_list_feature', type: 'text' },
{ name: 'prod_list_parameters', type: 'json' },
{ name: 'prod_list_plantype', type: 'text' },
{ name: 'prod_list_category', type: 'text', required: true },
{ name: 'prod_list_sort', type: 'number' },
{ name: 'prod_list_comm_type', type: 'text' },
{ name: 'prod_list_series', type: 'text' },
{ name: 'prod_list_power_supply', type: 'text' },
{ name: 'prod_list_tags', type: 'text' },
{ name: 'prod_list_status', type: 'text' },
{ name: 'prod_list_basic_price', type: 'number' },
{ name: 'prod_list_remark', type: 'text' },
],
indexes: [
'CREATE UNIQUE INDEX idx_tbl_product_list_prod_list_id ON tbl_product_list (prod_list_id)',
'CREATE INDEX idx_tbl_product_list_prod_list_name ON tbl_product_list (prod_list_name)',
'CREATE INDEX idx_tbl_product_list_prod_list_modelnumber ON tbl_product_list (prod_list_modelnumber)',
'CREATE INDEX idx_tbl_product_list_prod_list_status ON tbl_product_list (prod_list_status)',
'CREATE INDEX idx_tbl_product_list_prod_list_category ON tbl_product_list (prod_list_category)',
'CREATE INDEX idx_tbl_product_list_prod_list_sort ON tbl_product_list (prod_list_sort)',
'CREATE INDEX idx_tbl_product_list_category_sort ON tbl_product_list (prod_list_category, prod_list_sort)',
'CREATE INDEX idx_tbl_product_list_prod_list_series ON tbl_product_list (prod_list_series)',
'CREATE INDEX idx_tbl_product_list_prod_list_tags ON tbl_product_list (prod_list_tags)',
],
},
];
function normalizeFieldPayload(field, existingField) {
const payload = existingField
? Object.assign({}, existingField)
: {
name: field.name,
type: field.type,
};
if (existingField && existingField.id) {
payload.id = existingField.id;
}
payload.name = field.name;
payload.type = field.type;
if (typeof field.required !== 'undefined') {
payload.required = field.required;
}
if (field.type === 'autodate') {
payload.onCreate = typeof field.onCreate === 'boolean' ? field.onCreate : true;
payload.onUpdate = typeof field.onUpdate === 'boolean' ? field.onUpdate : false;
}
return payload;
}
function buildCollectionPayload(collectionData, existingCollection) {
if (!existingCollection) {
return {
name: collectionData.name,
type: collectionData.type,
listRule: Object.prototype.hasOwnProperty.call(collectionData, 'listRule') ? collectionData.listRule : null,
viewRule: Object.prototype.hasOwnProperty.call(collectionData, 'viewRule') ? collectionData.viewRule : null,
createRule: Object.prototype.hasOwnProperty.call(collectionData, 'createRule') ? collectionData.createRule : null,
updateRule: Object.prototype.hasOwnProperty.call(collectionData, 'updateRule') ? collectionData.updateRule : null,
deleteRule: Object.prototype.hasOwnProperty.call(collectionData, 'deleteRule') ? collectionData.deleteRule : null,
fields: collectionData.fields.map((field) => normalizeFieldPayload(field, null)),
indexes: collectionData.indexes,
};
}
const targetFieldMap = new Map(collectionData.fields.map((field) => [field.name, field]));
const fields = (existingCollection.fields || []).map((existingField) => {
const targetField = targetFieldMap.get(existingField.name);
if (!targetField) {
return existingField;
}
targetFieldMap.delete(existingField.name);
return normalizeFieldPayload(targetField, existingField);
});
for (const field of targetFieldMap.values()) {
fields.push(normalizeFieldPayload(field, null));
}
return {
name: collectionData.name,
type: collectionData.type,
listRule: Object.prototype.hasOwnProperty.call(collectionData, 'listRule') ? collectionData.listRule : existingCollection.listRule,
viewRule: Object.prototype.hasOwnProperty.call(collectionData, 'viewRule') ? collectionData.viewRule : existingCollection.viewRule,
createRule: Object.prototype.hasOwnProperty.call(collectionData, 'createRule') ? collectionData.createRule : existingCollection.createRule,
updateRule: Object.prototype.hasOwnProperty.call(collectionData, 'updateRule') ? collectionData.updateRule : existingCollection.updateRule,
deleteRule: Object.prototype.hasOwnProperty.call(collectionData, 'deleteRule') ? collectionData.deleteRule : existingCollection.deleteRule,
fields: fields,
indexes: collectionData.indexes,
};
}
function normalizeFieldList(fields) {
return (fields || []).map((field) => ({
name: field.name,
type: field.type,
required: !!field.required,
}));
}
async function createOrUpdateCollection(collectionData) {
console.log(`🔄 正在处理表: ${collectionData.name} ...`);
try {
const list = await pb.collections.getFullList({
sort: '-created',
});
const existing = list.find((item) => item.name === collectionData.name);
if (existing) {
await pb.collections.update(existing.id, buildCollectionPayload(collectionData, existing));
console.log(`♻️ ${collectionData.name} 已存在,已按最新结构更新。`);
return;
}
await pb.collections.create(buildCollectionPayload(collectionData, null));
console.log(`${collectionData.name} 创建完成。`);
} catch (error) {
console.error(`❌ 处理集合 ${collectionData.name} 失败:`, {
status: error.status,
message: error.message,
response: error.response,
});
throw error;
}
}
async function getCollectionByName(collectionName) {
const list = await pb.collections.getFullList({
sort: '-created',
});
return list.find((item) => item.name === collectionName) || null;
}
async function verifyCollections(targetCollections) {
console.log('\n🔍 开始校验产品表结构与索引...');
for (const target of targetCollections) {
const remote = await getCollectionByName(target.name);
if (!remote) {
throw new Error(`${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 (remote.type !== target.type) {
throw new Error(`${target.name} 类型不匹配,期望 ${target.type},实际 ${remote.type}`);
}
if (!missingFields.length && !mismatchedTypes.length && !mismatchedRequired.length && !missingIndexes.length) {
console.log(`${target.name} 校验通过。`);
continue;
}
console.log(`${target.name} 校验失败:`);
if (missingFields.length) {
console.log(` - 缺失字段: ${missingFields.join(', ')}`);
}
if (mismatchedTypes.length) {
console.log(` - 字段类型不匹配: ${mismatchedTypes.join(', ')}`);
}
if (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 载入认证状态。');
for (const collectionData of collections) {
await createOrUpdateCollection(collectionData);
}
await verifyCollections(collections);
console.log('\n🎉 产品表结构初始化并校验完成!');
} catch (error) {
console.error('❌ 初始化失败:', error.response?.data || error.message);
process.exitCode = 1;
}
}
init();