From 836e6608428f4f1327d8188b01d3f804a66423f5 Mon Sep 17 00:00:00 2001 From: XuJiacheng Date: Thu, 26 Mar 2026 17:59:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AD=97=E5=85=B8?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85=E6=8B=AC?= =?UTF-8?q?=E5=AD=97=E5=85=B8=E7=9A=84=E5=A2=9E=E5=88=A0=E6=94=B9=E6=9F=A5?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=92=8C=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增字典删除接口 `/api/dictionary/delete` - 新增字典详情接口 `/api/dictionary/detail` - 新增字典列表接口 `/api/dictionary/list` - 新增字典更新接口 `/api/dictionary/update` - 新增字典服务 `dictionaryService.js`,实现字典的创建、更新、删除和查询功能 - 新增字典管理页面 `dictionary-manage.js`,支持字典的增删改查操作 - 新增管理主页 `index.js`,提供字典管理入口 - 新增示例页面 `page-b.js`,用于验证页面跳转 --- pocket-base/README.md | 3 +- pocket-base/bai-api-main.pb.js | 5 + pocket-base/bai-web-main.pb.js | 1 + .../bai_api_routes/dictionary/create.js | 29 ++ .../bai_api_routes/dictionary/delete.js | 29 ++ .../bai_api_routes/dictionary/detail.js | 28 ++ .../bai_api_routes/dictionary/list.js | 30 ++ .../bai_api_routes/dictionary/update.js | 29 ++ .../bai_api_routes/platform/login.js | 4 +- .../bai_api_routes/platform/register.js | 5 +- .../bai_api_routes/system/health.js | 2 + .../bai_api_routes/system/refresh-token.js | 17 +- .../bai_api_routes/wechat/login.js | 5 +- .../bai_api_shared/config/env.js | 1 + .../bai_api_shared/config/runtime.example.js | 3 +- .../bai_api_shared/config/runtime.js | 3 +- .../middlewares/requestGuards.js | 102 +++- .../services/dictionaryService.js | 200 ++++++++ .../bai_api_shared/services/userService.js | 95 +++- .../bai_api_shared/utils/response.js | 16 + .../pages/dictionary-manage.js | 468 ++++++++++++++++++ pocket-base/bai_web_pb_hooks/pages/index.js | 71 +++ pocket-base/bai_web_pb_hooks/pages/page-a.js | 146 +++++- pocket-base/bai_web_pb_hooks/pages/page-b.js | 62 +++ ...6-03-23-pocketbase-hooks-auth-hardening.md | 140 +++++- pocket-base/spec/openapi.yaml | 410 +++++++++++++-- script/pocketbase.js | 5 +- 27 files changed, 1797 insertions(+), 112 deletions(-) create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/create.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/delete.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/detail.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/list.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/update.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js create mode 100644 pocket-base/bai_web_pb_hooks/pages/dictionary-manage.js create mode 100644 pocket-base/bai_web_pb_hooks/pages/index.js create mode 100644 pocket-base/bai_web_pb_hooks/pages/page-b.js diff --git a/pocket-base/README.md b/pocket-base/README.md index c8bbc6a..c695eec 100644 --- a/pocket-base/README.md +++ b/pocket-base/README.md @@ -58,7 +58,7 @@ pocket-base/ - 登录接口:`POST /api/platform/login` - 平台用户注册时会自动生成 GUID 并写入 `tbl_auth_users.openid` - 同时写入 `users_idtype = ManagePlatform` -- 平台登录对前端暴露为 `users_phone + password` +- 平台登录对前端暴露为 `login_account + password`,其中 `login_account` 支持邮箱或手机号 - 服务端内部仍使用 PocketBase 原生 password auth,以确保返回原生 `token` ### 通用认证能力 @@ -120,6 +120,7 @@ PocketBase JSVM 不会自动读取 `back-end/.env`。当前 Hook 运行配置来 - 旧 `back-end/.env` 中的这些值可以作为来源参考,但 **不会被 pb_hooks 自动读取**。 - 如果不方便改 PocketBase 进程环境,可在服务器创建:`pb_hooks/bai_api_pb_hooks/bai_api_shared/config/runtime.js`。 - `runtime.js` 的内容可直接参考 `runtime.example.js`。 +- 若 hooks 内部需要回源调用 PocketBase REST(如 `auth-with-password`),`POCKETBASE_API_URL` 不应使用外部 HTTPS 域名,建议使用 PocketBase 进程/容器内可达地址,例如 `http://127.0.0.1:8080`,以避免 Nginx 443 回环失败。 - 当前 Hook 代码已不依赖自定义 JWT,因此 `JWT_SECRET`、`JWT_EXPIRES_IN` 不是运行必需项。 ## 额外要求 diff --git a/pocket-base/bai-api-main.pb.js b/pocket-base/bai-api-main.pb.js index dda3fae..d16ebde 100644 --- a/pocket-base/bai-api-main.pb.js +++ b/pocket-base/bai-api-main.pb.js @@ -23,5 +23,10 @@ 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/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`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/update.js`) +require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/delete.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/login.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/profile.js`) diff --git a/pocket-base/bai-web-main.pb.js b/pocket-base/bai-web-main.pb.js index 8f1d652..5ed467d 100644 --- a/pocket-base/bai-web-main.pb.js +++ b/pocket-base/bai-web-main.pb.js @@ -1,3 +1,4 @@ require(`${__hooks}/bai_web_pb_hooks/pages/index.js`) require(`${__hooks}/bai_web_pb_hooks/pages/page-a.js`) require(`${__hooks}/bai_web_pb_hooks/pages/page-b.js`) +require(`${__hooks}/bai_web_pb_hooks/pages/dictionary-manage.js`) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/create.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/create.js new file mode 100644 index 0000000..de7ad1e --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/create.js @@ -0,0 +1,29 @@ +routerAdd('POST', '/api/dictionary/create', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const dictionaryService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateDictionaryMutationBody(e, false) + const data = dictionaryService.createDictionary(payload) + + return success(e, '新增字典成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('新增字典失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '新增字典失败', + data: (err && err.data) || {}, + }) + } +}) \ No newline at end of file diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/delete.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/delete.js new file mode 100644 index 0000000..c77fce1 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/delete.js @@ -0,0 +1,29 @@ +routerAdd('POST', '/api/dictionary/delete', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const dictionaryService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateDictionaryDeleteBody(e) + const data = dictionaryService.deleteDictionary(payload.dict_name) + + return success(e, '删除字典成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('删除字典失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '删除字典失败', + data: (err && err.data) || {}, + }) + } +}) \ No newline at end of file diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/detail.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/detail.js new file mode 100644 index 0000000..53499b2 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/detail.js @@ -0,0 +1,28 @@ +routerAdd('POST', '/api/dictionary/detail', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const dictionaryService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + + const payload = guards.validateDictionaryDetailBody(e) + const data = dictionaryService.getDictionaryByName(payload.dict_name) + + return success(e, '查询字典详情成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('查询字典详情失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '查询字典详情失败', + data: (err && err.data) || {}, + }) + } +}) \ No newline at end of file diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/list.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/list.js new file mode 100644 index 0000000..faea7a3 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/list.js @@ -0,0 +1,30 @@ +routerAdd('POST', '/api/dictionary/list', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const dictionaryService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + + const payload = guards.validateDictionaryListBody(e) + const data = dictionaryService.listDictionaries(payload.keyword) + + return success(e, '查询字典列表成功', { + items: data, + }) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('查询字典列表失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '查询字典列表失败', + data: (err && err.data) || {}, + }) + } +}) \ No newline at end of file diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/update.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/update.js new file mode 100644 index 0000000..f682b89 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/dictionary/update.js @@ -0,0 +1,29 @@ +routerAdd('POST', '/api/dictionary/update', function (e) { + const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) + const dictionaryService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js`) + const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + try { + guards.requireJson(e) + guards.requireManagePlatformUser(e) + guards.duplicateGuard(e) + + const payload = guards.validateDictionaryMutationBody(e, true) + const data = dictionaryService.updateDictionary(payload) + + return success(e, '修改字典成功', data) + } catch (err) { + const status = (err && err.statusCode) || (err && err.status) || 400 + logger.error('修改字典失败', { + status: status, + message: (err && err.message) || '未知错误', + data: (err && err.data) || {}, + }) + return e.json(status, { + code: status, + msg: (err && err.message) || '修改字典失败', + data: (err && err.data) || {}, + }) + } +}) \ No newline at end of file diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/platform/login.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/platform/login.js index 05cbc27..05c46fa 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_routes/platform/login.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/platform/login.js @@ -2,6 +2,7 @@ routerAdd('POST', '/api/platform/login', function (e) { const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { successWithToken } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) try { guards.requireJson(e) @@ -10,8 +11,7 @@ routerAdd('POST', '/api/platform/login', function (e) { const payload = guards.validatePlatformLoginBody(e) const data = userService.authenticatePlatformUser(payload) - $apis.recordAuthResponse(e, data.authRecord, data.authMethod, data.meta) - return + return successWithToken(e, '登录成功', data.meta.data, userService.ensureAuthToken(data.token)) } catch (err) { const status = (err && typeof err.statusCode === 'number' && err.statusCode) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/platform/register.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/platform/register.js index df9b597..6b0a983 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_routes/platform/register.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/platform/register.js @@ -2,6 +2,7 @@ routerAdd('POST', '/api/platform/register', function (e) { const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { successWithToken } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) try { guards.requireJson(e) @@ -9,9 +10,9 @@ routerAdd('POST', '/api/platform/register', function (e) { const payload = guards.validatePlatformRegisterBody(e) const data = userService.registerPlatformUser(payload) + const token = userService.issueAuthToken(data.authRecord) - $apis.recordAuthResponse(e, data.authRecord, data.authMethod, data.meta) - return + return successWithToken(e, '注册成功', data.meta.data, token) } catch (err) { const status = (err && typeof err.statusCode === 'number' && err.statusCode) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/system/health.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/health.js index b7dc484..230ed98 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_routes/system/health.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/health.js @@ -1,8 +1,10 @@ routerAdd('POST', '/api/system/health', function (e) { + const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`) const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) return success(e, '服务运行正常', { status: 'healthy', + version: env.appVersion, timestamp: new Date().toISOString(), }) }) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/system/refresh-token.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/refresh-token.js index a5415cd..2f38248 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_routes/system/refresh-token.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/refresh-token.js @@ -2,6 +2,7 @@ routerAdd('POST', '/api/system/refresh-token', function (e) { const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { successWithToken } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) try { guards.requireJson(e) @@ -14,13 +15,7 @@ routerAdd('POST', '/api/system/refresh-token', function (e) { const data = userService.refreshAuthToken(openid) const token = userService.issueAuthToken(data.authRecord) - return e.json(200, { - code: 200, - msg: '刷新成功', - data: { - token: token, - }, - }) + return successWithToken(e, '刷新成功', {}, token) } if (!payload.users_wx_code) { @@ -34,13 +29,7 @@ routerAdd('POST', '/api/system/refresh-token', function (e) { const authData = userService.authenticateWechatUser(payload) const token = userService.issueAuthToken(authData.authRecord) - return e.json(200, { - code: 200, - msg: '刷新成功', - data: { - token: token, - }, - }) + return successWithToken(e, '刷新成功', {}, token) } catch (err) { const status = (err && typeof err.statusCode === 'number' && err.statusCode) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/login.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/login.js index 68b2c3f..32004b3 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/login.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/login.js @@ -2,6 +2,7 @@ routerAdd('POST', '/api/wechat/login', function (e) { const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) + const { successWithToken } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) try { guards.requireJson(e) @@ -9,9 +10,9 @@ routerAdd('POST', '/api/wechat/login', function (e) { const payload = guards.validateLoginBody(e) const data = userService.authenticateWechatUser(payload) + const token = userService.issueAuthToken(data.authRecord) - $apis.recordAuthResponse(e, data.authRecord, data.authMethod, data.meta) - return + return successWithToken(e, data.status === 'register_success' ? '注册成功' : '登录成功', data.meta.data, token) } catch (err) { const status = (err && typeof err.statusCode === 'number' && err.statusCode) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.js index b54acab..66fde6b 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.js @@ -20,6 +20,7 @@ function pick(key, fallback) { module.exports = { nodeEnv: pick('NODE_ENV', 'production'), apiPrefix: '/api', + appVersion: pick('APP_VERSION', 'dev-local'), wechatAppId: pick('WECHAT_APPID', ''), wechatSecret: pick('WECHAT_SECRET', ''), pocketbaseApiUrl: pick('POCKETBASE_API_URL', ''), diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.example.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.example.js index ea426f0..b16b32c 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.example.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.example.js @@ -1,7 +1,8 @@ module.exports = { NODE_ENV: 'production', + APP_VERSION: 'temp-dev-local', APP_BASE_URL: 'https://bai-api.blv-oa.com', - POCKETBASE_API_URL: 'https://bai-api.blv-oa.com/pb/', + POCKETBASE_API_URL: 'http://127.0.0.1:8090', POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo', /* WECHAT_APPID: 'wx3bd7a7b19679da7a', WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1', */ diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js index 729da21..64b691c 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js @@ -1,7 +1,8 @@ module.exports = { NODE_ENV: 'production', + APP_VERSION: '0.1.21', APP_BASE_URL: 'https://bai-api.blv-oa.com', - POCKETBASE_API_URL: 'https://bai-api.blv-oa.com/pb/', + POCKETBASE_API_URL: 'http://127.0.0.1:8090', POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTgwNTk2MzM4NywiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.R34MTotuFBpevqbbUtvQ3GPa0Z1mpY8eQwZgDdmfhMo', WECHAT_APPID: 'wx3bd7a7b19679da7a', WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1', 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 f0d3347..b61c981 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 @@ -50,10 +50,15 @@ function validatePlatformRegisterBody(e) { function validatePlatformLoginBody(e) { const payload = parseBody(e) - if (!payload.users_phone) throw createAppError(400, 'users_phone 为必填项') + const loginAccount = payload.login_account || payload.users_phone || payload.email || '' + + if (!loginAccount) throw createAppError(400, 'login_account 为必填项,支持邮箱或手机号') if (!payload.password) throw createAppError(400, 'password 为必填项') - return payload + return { + login_account: loginAccount, + password: payload.password, + } } function validateSystemRefreshBody(e) { @@ -74,6 +79,94 @@ function validateSystemRefreshBody(e) { } } +function requireManagePlatformUser(e) { + const authUser = requireAuthUser(e) + const idType = authUser.authRecord.getString('users_idtype') + + if (idType !== 'ManagePlatform') { + throw createAppError(403, '仅平台管理用户可访问') + } + + return authUser +} + +function validateDictionaryListBody(e) { + const payload = parseBody(e) + return { + keyword: payload.keyword || '', + } +} + +function validateDictionaryDetailBody(e) { + const payload = parseBody(e) + if (!payload.dict_name) { + throw createAppError(400, 'dict_name 为必填项') + } + + return { + dict_name: payload.dict_name, + } +} + +function normalizeDictionaryItem(item, index) { + const current = sanitizePayload(item || {}) + if (!current.enum) { + throw createAppError(400, '第 ' + (index + 1) + ' 项 enum 为必填项') + } + + if (!current.description) { + throw createAppError(400, '第 ' + (index + 1) + ' 项 description 为必填项') + } + + const sortOrderNumber = Number(current.sortOrder) + if (!Number.isFinite(sortOrderNumber)) { + throw createAppError(400, '第 ' + (index + 1) + ' 项 sortOrder 必须为数字') + } + + return { + enum: String(current.enum), + description: String(current.description), + sortOrder: sortOrderNumber, + } +} + +function validateDictionaryMutationBody(e, isUpdate) { + const payload = parseBody(e) + + if (!payload.dict_name) { + throw createAppError(400, 'dict_name 为必填项') + } + + const items = Array.isArray(payload.items) ? payload.items : [] + if (!items.length) { + throw createAppError(400, 'items 至少需要一项') + } + + const normalizedItems = items.map(function (item, index) { + return normalizeDictionaryItem(item, index) + }) + + return { + dict_name: payload.dict_name, + dict_word_is_enabled: typeof payload.dict_word_is_enabled === 'boolean' ? payload.dict_word_is_enabled : true, + dict_word_parent_id: payload.dict_word_parent_id || '', + dict_word_remark: payload.dict_word_remark || '', + items: normalizedItems, + original_dict_name: isUpdate ? (payload.original_dict_name || payload.dict_name) : '', + } +} + +function validateDictionaryDeleteBody(e) { + const payload = parseBody(e) + if (!payload.dict_name) { + throw createAppError(400, 'dict_name 为必填项') + } + + return { + dict_name: payload.dict_name, + } +} + function requireAuthOpenid(e) { if (!e.auth) { throw createAppError(401, '认证令牌无效或已过期') @@ -138,6 +231,11 @@ module.exports = { validatePlatformRegisterBody, validatePlatformLoginBody, validateSystemRefreshBody, + requireManagePlatformUser, + validateDictionaryListBody, + validateDictionaryDetailBody, + validateDictionaryMutationBody, + validateDictionaryDeleteBody, requireAuthOpenid, requireAuthUser, duplicateGuard, 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 new file mode 100644 index 0000000..8e8c0cb --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js @@ -0,0 +1,200 @@ +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`) + +function buildSystemDictId() { + return 'DICT-' + new Date().getTime() + '-' + $security.randomString(6) +} + +function safeJsonParse(text, fallback) { + if (!text) return fallback + + try { + return JSON.parse(text) + } catch (err) { + return fallback + } +} + +function normalizeItemsFromRecord(record) { + const enums = safeJsonParse(record.getString('dict_word_enum'), []) + const descriptions = safeJsonParse(record.getString('dict_word_description'), []) + const sortOrders = safeJsonParse(record.getString('dict_word_sort_order'), []) + const maxLength = Math.max(enums.length, descriptions.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') { + continue + } + + items.push({ + enum: typeof enums[i] === 'undefined' ? '' : String(enums[i]), + description: typeof descriptions[i] === 'undefined' ? '' : String(descriptions[i]), + sortOrder: Number(sortOrders[i] || 0), + }) + } + + return items +} + +function exportDictionaryRecord(record) { + return { + pb_id: record.id, + system_dict_id: record.getString('system_dict_id'), + dict_name: record.getString('dict_name'), + dict_word_is_enabled: !!record.get('dict_word_is_enabled'), + dict_word_parent_id: record.getString('dict_word_parent_id'), + dict_word_remark: record.getString('dict_word_remark'), + items: normalizeItemsFromRecord(record), + created: String(record.created || ''), + updated: String(record.updated || ''), + } +} + +function findDictionaryByName(dictName) { + const records = $app.findRecordsByFilter('tbl_system_dict', 'dict_name = {:dictName}', '', 1, 0, { + dictName: dictName, + }) + + return records.length ? records[0] : null +} + +function ensureDictionaryNameUnique(dictName, excludeId) { + const existing = findDictionaryByName(dictName) + if (existing && existing.id !== excludeId) { + throw createAppError(400, 'dict_name 已存在') + } +} + +function fillDictionaryItems(record, items) { + const enums = [] + const descriptions = [] + const sortOrders = [] + + for (let i = 0; i < items.length; i += 1) { + enums.push(items[i].enum) + descriptions.push(items[i].description) + 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_sort_order', JSON.stringify(sortOrders)) +} + +function listDictionaries(keyword) { + const allRecords = $app.findRecordsByFilter('tbl_system_dict', '', 'dict_name', 500, 0) + const normalizedKeyword = String(keyword || '').toLowerCase() + const result = [] + + for (let i = 0; i < allRecords.length; i += 1) { + const item = exportDictionaryRecord(allRecords[i]) + if (!normalizedKeyword || item.dict_name.toLowerCase().indexOf(normalizedKeyword) !== -1) { + result.push(item) + } + } + + return result +} + +function getDictionaryByName(dictName) { + const record = findDictionaryByName(dictName) + if (!record) { + throw createAppError(404, '未找到对应字典') + } + + return exportDictionaryRecord(record) +} + +function createDictionary(payload) { + ensureDictionaryNameUnique(payload.dict_name) + + const collection = $app.findCollectionByNameOrId('tbl_system_dict') + const record = new Record(collection) + record.set('system_dict_id', buildSystemDictId()) + record.set('dict_name', payload.dict_name) + record.set('dict_word_is_enabled', payload.dict_word_is_enabled) + record.set('dict_word_parent_id', payload.dict_word_parent_id) + record.set('dict_word_remark', payload.dict_word_remark) + fillDictionaryItems(record, payload.items) + + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '创建字典失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('字典创建成功', { + dict_name: payload.dict_name, + system_dict_id: record.getString('system_dict_id'), + }) + + return exportDictionaryRecord(record) +} + +function updateDictionary(payload) { + const record = findDictionaryByName(payload.original_dict_name) + if (!record) { + throw createAppError(404, '未找到待修改的字典') + } + + ensureDictionaryNameUnique(payload.dict_name, record.id) + + record.set('dict_name', payload.dict_name) + record.set('dict_word_is_enabled', payload.dict_word_is_enabled) + record.set('dict_word_parent_id', payload.dict_word_parent_id) + record.set('dict_word_remark', payload.dict_word_remark) + fillDictionaryItems(record, payload.items) + + try { + $app.save(record) + } catch (err) { + throw createAppError(400, '修改字典失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('字典修改成功', { + dict_name: payload.dict_name, + original_dict_name: payload.original_dict_name, + }) + + return exportDictionaryRecord(record) +} + +function deleteDictionary(dictName) { + const record = findDictionaryByName(dictName) + if (!record) { + throw createAppError(404, '未找到待删除的字典') + } + + try { + $app.delete(record) + } catch (err) { + throw createAppError(400, '删除字典失败', { + originalMessage: (err && err.message) || '未知错误', + originalData: (err && err.data) || {}, + }) + } + + logger.info('字典删除成功', { + dict_name: dictName, + system_dict_id: record.getString('system_dict_id'), + }) + + return { + dict_name: dictName, + } +} + +module.exports = { + listDictionaries, + getDictionaryByName, + createDictionary, + updateDictionary, + deleteDictionary, +} \ No newline at end of file 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 55118f2..bea8bc1 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 @@ -1,6 +1,7 @@ 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 wechatService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/wechatService.js`) +const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`) const GUEST_USER_TYPE = '游客' const REGISTERED_USER_TYPE = '注册用户' @@ -64,6 +65,48 @@ function findUserByPhone(usersPhone) { return records.length ? records[0] : null } +function findUserByEmail(email) { + const records = $app.findRecordsByFilter('tbl_auth_users', 'email = {:email}', '', 1, 0, { + email: email, + }) + return records.length ? records[0] : null +} + +function isEmail(value) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || '')) +} + +function parseHttpJsonResponse(response) { + if (!response) { + throw createAppError(500, 'PocketBase 认证响应为空') + } + + if (response.json && typeof response.json === 'object') { + return response.json + } + + if (typeof response.body === 'string' && response.body) { + return JSON.parse(response.body) + } + + if (response.body && typeof response.body === 'object') { + return response.body + } + + if (typeof response.data === 'string' && response.data) { + return JSON.parse(response.data) + } + + if (response.data && typeof response.data === 'object') { + return response.data + } + + throw createAppError(500, 'PocketBase 认证响应体为空', { + statusCode: response.statusCode || 0, + responseKeys: Object.keys(response || {}), + }) +} + function getCompanyByCompanyId(companyId) { if (!companyId) return null const records = $app.findRecordsByFilter('tbl_company', 'company_id = {:companyId}', '', 1, 0, { @@ -248,7 +291,7 @@ function registerPlatformUser(payload) { record.set('users_promo_code', payload.users_promo_code || '') record.set('usergroups_id', payload.usergroups_id || '') record.set('users_auth_type', 0) - record.set('email', platformOpenid + '@manage.local') + record.set('email', payload.email || (platformOpenid + '@manage.local')) record.setPassword(payload.password) record.set('passwordConfirm', payload.passwordConfirm) @@ -278,14 +321,18 @@ function registerPlatformUser(payload) { } function authenticatePlatformUser(payload) { - return withUserLock('platform-login:' + payload.users_phone, function () { - const userRecord = findUserByPhone(payload.users_phone) + return withUserLock('platform-login:' + payload.login_account, function () { + const loginAccount = String(payload.login_account || '') + const userRecord = isEmail(loginAccount) + ? findUserByEmail(loginAccount) + : findUserByPhone(loginAccount) + if (!userRecord) { throw createAppError(404, '平台用户不存在') } if (userRecord.getString('users_idtype') !== MANAGE_PLATFORM_ID_TYPE) { - throw createAppError(400, '当前手机号对应的不是平台用户') + throw createAppError(400, '当前登录账号对应的不是平台用户') } const identity = userRecord.getString('email') @@ -293,8 +340,32 @@ function authenticatePlatformUser(payload) { throw createAppError(500, '平台用户缺少原生登录标识') } - const authData = $apis.recordAuthWithPassword('tbl_auth_users', identity, payload.password) - const authRecord = authData.record || userRecord + const baseUrl = String(env.pocketbaseApiUrl || '').replace(/\/+$/, '') + if (!baseUrl) { + throw createAppError(500, '缺少 POCKETBASE_API_URL 配置') + } + + const authResponse = $http.send({ + url: baseUrl + '/api/collections/tbl_auth_users/auth-with-password', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + identity: identity, + password: payload.password, + }), + }) + + if (!authResponse || authResponse.statusCode < 200 || authResponse.statusCode >= 300) { + throw createAppError(400, '平台登录失败', { + statusCode: authResponse ? authResponse.statusCode : 0, + body: authResponse ? String(authResponse.body || '') : '', + }) + } + + const authData = parseHttpJsonResponse(authResponse) + const authRecord = userRecord const user = enrichUser(authRecord) @@ -302,6 +373,7 @@ function authenticatePlatformUser(payload) { users_id: user.users_id, openid: user.openid, users_idtype: user.users_idtype, + login_account: loginAccount, }) return { @@ -310,6 +382,8 @@ function authenticatePlatformUser(payload) { user: user, authRecord: authRecord, authMethod: '', + token: issueAuthToken(authRecord), + pbRecord: authData.record || null, meta: buildAuthMeta({ status: 'login_success', is_info_complete: isInfoComplete(authRecord), @@ -409,9 +483,18 @@ function issueAuthToken(authRecord) { return token } +function ensureAuthToken(token) { + if (!token) { + throw createAppError(500, '签发令牌失败:生成 token 为空') + } + + return token +} + module.exports = { authenticateWechatUser, authenticatePlatformUser, + ensureAuthToken, updateWechatUserProfile, refreshAuthToken, issueAuthToken, diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/response.js b/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/response.js index 7eda0b3..2ac2286 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/response.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/utils/response.js @@ -6,6 +6,22 @@ function success(e, msg, data, code) { }) } +function successWithToken(e, msg, data, token, code) { + const statusCode = code || 200 + const payload = { + code: statusCode, + msg: msg || '操作成功', + data: data || {}, + } + + if (token) { + payload.token = token + } + + return e.json(statusCode, payload) +} + module.exports = { success, + successWithToken, } diff --git a/pocket-base/bai_web_pb_hooks/pages/dictionary-manage.js b/pocket-base/bai_web_pb_hooks/pages/dictionary-manage.js new file mode 100644 index 0000000..f7136bf --- /dev/null +++ b/pocket-base/bai_web_pb_hooks/pages/dictionary-manage.js @@ -0,0 +1,468 @@ +routerAdd('GET', '/manage/dictionary-manage', function (e) { + const html = ` + + + + + 字典管理 + + + + +
+
+

字典管理

+

页面仅允许 ManagePlatform 用户操作。当前请求会自动使用登录页保存到 localStorage 的 token。

+
+ 返回主页 + 登录页 + + +
+
+
+ +
+
+ + + + + +
+
+ + + + + + + + + + + + + + + +
dict_name启用备注parent_id枚举项创建时间操作
暂无数据,请先查询。
+
+
+
+ + + + + +` + + return e.html(200, html) +}) \ No newline at end of file diff --git a/pocket-base/bai_web_pb_hooks/pages/index.js b/pocket-base/bai_web_pb_hooks/pages/index.js new file mode 100644 index 0000000..ee06520 --- /dev/null +++ b/pocket-base/bai_web_pb_hooks/pages/index.js @@ -0,0 +1,71 @@ +routerAdd('GET', '/manage', function (e) { + const html = ` + + + + + 管理主页 + + + + +
+
+

管理主页

+

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

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

字典管理

+

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

+ 进入字典管理 +
+
+

登录页

+

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

+ 打开登录页 +
+
+

页面二

+

第二个示例子页面,便于验证主页跳转与 hooks 页面导航。

+ 进入页面二 +
+
+
+ +
+
+
+ + +` + + return e.html(200, html) +}) \ No newline at end of file diff --git a/pocket-base/bai_web_pb_hooks/pages/page-a.js b/pocket-base/bai_web_pb_hooks/pages/page-a.js index b591dfb..ce5426a 100644 --- a/pocket-base/bai_web_pb_hooks/pages/page-a.js +++ b/pocket-base/bai_web_pb_hooks/pages/page-a.js @@ -1,52 +1,158 @@ -routerAdd('GET', '/web/page-a', function (e) { +function renderLoginPage(e) { const html = ` - 页面一 + 登录 +
-

页面一

-

这里是 page-a 页面。后续你可以把它扩展成介绍页、帮助页、数据展示页或简单表单页。

- +

后台登录

+

请输入平台账号(邮箱或手机号)与密码,登录成功后会自动跳转到管理首页。

+
+
+ + +
+
+ + +
+ +
+
+ ` return e.html(200, html) -}) +} + +routerAdd('GET', '/manage/login', renderLoginPage) +routerAdd('GET', '/manage/page-a', renderLoginPage) diff --git a/pocket-base/bai_web_pb_hooks/pages/page-b.js b/pocket-base/bai_web_pb_hooks/pages/page-b.js new file mode 100644 index 0000000..7df38ab --- /dev/null +++ b/pocket-base/bai_web_pb_hooks/pages/page-b.js @@ -0,0 +1,62 @@ +routerAdd('GET', '/manage/page-b', function (e) { + const html = ` + + + + + 页面二 + + + + +
+
+

页面二

+

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

+ +
+
+ +` + + return e.html(200, html) +}) \ No newline at end of file diff --git a/pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md b/pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md index 818adfd..cd7832a 100644 --- a/pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md +++ b/pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md @@ -6,7 +6,7 @@ ## 范围 -本次变更覆盖 `pocket-base/` 下 PocketBase hooks 项目的微信登录、平台注册/登录、资料更新、token 刷新、认证落库、错误可观测性、索引策略与运行规范。 +本次变更覆盖 `pocket-base/` 下 PocketBase hooks 项目的微信登录、平台注册/登录、资料更新、token 刷新、认证落库、错误可观测性、索引策略、字典管理、页面辅助操作、健康检查版本探针、OpenAPI 鉴权与统一响应规范。 --- @@ -15,7 +15,7 @@ ### 1. 认证体系 - 保持 PocketBase 原生 auth token 作为唯一正式认证令牌。 -- 登录与刷新响应统一通过 `$apis.recordAuthResponse(...)` 返回 PocketBase 原生认证结果。 +- 登录与刷新响应统一为项目标准结构:`code`、`msg`、`data`,认证成功时额外返回顶层 `token`。 - `authMethod` 统一使用空字符串 `''`,避免触发不必要的 MFA / login alerts 校验。 ### 2. Header 规则 @@ -139,6 +139,11 @@ - `POST /api/platform/login` - `POST /api/wechat/login` - `POST /api/wechat/profile` +- `POST /api/dictionary/list` +- `POST /api/dictionary/detail` +- `POST /api/dictionary/create` +- `POST /api/dictionary/update` +- `POST /api/dictionary/delete` 其中平台用户链路补充为: @@ -147,14 +152,15 @@ - body 必填:`users_name`、`users_phone`、`password`、`passwordConfirm`、`users_picture` - 自动生成 GUID 并写入统一身份字段 `openid` - 写入 `users_idtype = ManagePlatform` -- 成功时返回 PocketBase 原生 token + auth record + meta +- 成功时返回统一结构:`code`、`msg`、`data`,并在顶层额外返回 `token` ### `POST /api/platform/login` -- body 必填:`users_phone`、`password` +- body 必填:`login_account`、`password` - 仅允许 `users_idtype = ManagePlatform` -- 前端使用手机号+密码提交 -- 服务端内部仍通过 PocketBase 原生 password auth 返回原生 token +- 前端使用邮箱或手机号 + 密码提交 +- 服务端先通过 PocketBase `auth-with-password` 校验身份,再由当前 hooks 进程签发正式 token +- 成功时返回统一结构:`code`、`msg`、`data`,并在顶层额外返回 `token` 其中: @@ -164,7 +170,7 @@ - 自动以微信 code 换取微信侧 `openid` 并写入统一身份字段 - 若不存在 auth 用户则尝试创建 `tbl_auth_users` 记录 - 写入 `users_idtype = WeChat` -- 成功时返回 PocketBase 原生 token + auth record + meta +- 成功时返回统一结构:`code`、`msg`、`data`,并在顶层额外返回 `token` ### `POST /api/wechat/profile` @@ -179,25 +185,137 @@ - 若 token 仍有效:基于当前 auth record 续签 - 若 token 已过期:回退到微信 code 重签流程 - 若 token 已过期且未提供 `users_wx_code`,返回:`token已过期,请上传users_wx_code` -- 返回精简结构,仅返回新 token(不返回完整登录用户信息) +- 返回统一结构:`code`、`msg`、`data`,并在顶层额外返回新 `token` - 属于系统级通用认证能力,不限定为微信专属接口 +### 字典管理接口 + +新增 `dictionary` 分类接口,统一要求平台管理用户访问: + +- `POST /api/dictionary/list` + - 支持按 `dict_name` 模糊搜索 + - 返回字典全量信息,并将 `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 组装为 `items` +- `POST /api/dictionary/detail` + - 按 `dict_name` 查询单条字典 +- `POST /api/dictionary/create` + - 新增字典,`system_dict_id` 自动生成,`dict_name` 唯一 +- `POST /api/dictionary/update` + - 按 `original_dict_name` / `dict_name` 更新字典 +- `POST /api/dictionary/delete` + - 按 `dict_name` 真删除字典 + +说明: + +- `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 已统一改为 JSON 字符串持久化,其中 `dict_word_sort_order` 已改为 `text` 类型。 +- 查询时统一聚合为:`items: [{ enum, description, sortOrder }]` + --- -## 七、当前已知边界 +## 七、页面与运维辅助能力新增 + +### 1. PocketBase 页面 + +新增页面: + +- `/web` +- `/web/dictionary-manage` + +页面能力: + +- 首页支持跳转到子页面 +- 字典管理页支持: + - Bearer Token 粘贴与本地保存 + - `dict_name` 模糊搜索 + - 指定字典查询 + - 行内编辑基础字段 + - 弹窗编辑枚举项 + - 新增 / 删除字典 + - 返回主页 + +### 2. 健康检查版本探针 + +`POST /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/` -- `script/pocketbase.newpb.js` +- `pocket-base/bai_web_pb_hooks/` +- `pocket-base/spec/openapi.yaml` +- `script/pocketbase.js` 并在 PocketBase 环境中执行 schema 同步后重启服务,再进行接口验证。 + +建议归档后的发布核验顺序: + +1. `POST /api/system/health`:确认 `data.version` +2. `POST /api/platform/login`:确认返回统一结构与顶层 `token` +3. `POST /api/dictionary/list`:确认鉴权与字典接口可用 + +--- + +## 十一、归档状态 + +- 已将本轮字典管理、PocketBase 页面、统一响应、平台登录兼容修复、健康检查版本探针、OpenAPI/Apifox 鉴权策略调整并入本变更记录。 +- 本记录可作为当前 PocketBase hooks 阶段性归档基线继续维护。 diff --git a/pocket-base/spec/openapi.yaml b/pocket-base/spec/openapi.yaml index 6101825..c0d15fb 100644 --- a/pocket-base/spec/openapi.yaml +++ b/pocket-base/spec/openapi.yaml @@ -6,6 +6,7 @@ info: 当前 `tbl_auth_users.openid` 已被定义为全平台统一身份锚点: - 微信用户:`openid = 微信 openid` - 平台用户:`openid = 服务端生成的 GUID` + 请在 Apifox 环境中统一设置全局 Header:`Authorization: Bearer {{token}}`。 version: 1.0.0 servers: - url: https://bai-api.blv-oa.com/pb @@ -19,12 +20,9 @@ tags: description: 面向微信用户的认证接口;认证成功后仍统一使用全平台 `openid` 与 PocketBase 原生 token。 - name: 平台认证 description: 面向平台用户的认证接口;平台用户会生成 GUID 并写入统一 `openid` 字段。 + - name: 字典管理 + description: 面向 ManagePlatform 用户的系统字典维护接口。 components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: PocketBaseAuthToken schemas: ApiResponse: type: object @@ -45,6 +43,10 @@ components: status: type: string example: healthy + version: + type: string + description: 当前已部署 hooks 版本号,用于确认发布是否生效 + example: 2026.03.26-health-probe.1 timestamp: type: string format: date-time @@ -94,7 +96,7 @@ components: users_id_number: type: string users_status: - type: number + type: string users_rank_level: type: number users_auth_type: @@ -132,34 +134,55 @@ components: PocketBaseAuthResponse: type: object description: | - PocketBase 原生认证响应。 - 客户端可直接使用返回的 `token` 与 PocketBase SDK 或当前 hooks 接口交互。 + 项目统一认证响应。 + 所有对外接口统一返回 `code`、`msg`、`data`,认证成功时额外返回顶层 `token`。 properties: - token: + code: + type: integer + example: 200 + msg: type: string - description: PocketBase 原生 auth token - record: - type: object - description: PocketBase auth record 原始对象 - meta: + example: 登录成功 + data: type: object properties: - code: - type: integer - example: 200 - msg: + status: 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' + 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] @@ -227,12 +250,13 @@ components: type: string PlatformLoginRequest: type: object - required: [users_phone, password] - description: 平台用户登录请求体;前端使用手机号+密码提交,服务端内部转换为 PocketBase 原生 password auth。 + required: [login_account, password] + description: 平台用户登录请求体;前端使用邮箱或手机号 + 密码提交,服务端内部转换为 PocketBase 原生 password auth。 properties: - users_phone: + login_account: type: string - example: 13800138000 + description: 支持邮箱或手机号 + example: admin@example.com password: type: string example: 12345678 @@ -251,10 +275,89 @@ components: example: 0a1b2c3d4e5f6g RefreshTokenData: type: object + properties: {} + DictionaryItem: + type: object + required: [enum, description, sortOrder] properties: - token: + enum: type: string - description: 新签发的 PocketBase 原生 auth token + example: enabled + description: + type: string + example: 启用 + 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 + items: + $ref: '#/components/schemas/DictionaryItem' + DictionaryDeleteRequest: + type: object + required: [dict_name] + properties: + dict_name: + type: string + example: 用户状态 paths: /api/system/test-helloworld: post: @@ -314,13 +417,6 @@ paths: 1) 若 `Authorization` 对应 token 仍有效:直接按当前 auth record 续签(不调用微信接口)。 2) 若 token 已过期:仅在 body 提供 `users_wx_code` 时才走微信 code 重新签发。 返回体仅包含新的 `token`,不返回完整登录用户信息。 - parameters: - - in: header - name: Authorization - required: false - schema: - type: string - description: 可选。建议传入旧 token(`Bearer `)以优先走有效 token 续签路径。 requestBody: required: false content: @@ -337,8 +433,14 @@ paths: - $ref: '#/components/schemas/ApiResponse' - type: object properties: - data: - $ref: '#/components/schemas/RefreshTokenData' + token: + type: string + description: 新签发的 PocketBase 原生 auth token + example: + code: 200 + msg: 刷新成功 + data: {} + token: eyJhbGciOi... '400': description: 参数错误或微信侧身份换取失败 '401': @@ -416,9 +518,9 @@ paths: tags: [平台认证] summary: 平台用户登录 description: | - 前端使用平台注册时保存的 `users_phone + password` 登录。 + 前端使用平台注册时保存的 `邮箱或手机号 + password` 登录。 仅允许 `users_idtype = ManagePlatform` 的用户通过该接口登录。 - 服务端会先按手机号定位平台用户,再使用该用户的 PocketBase 原生 identity(当前为 `email`)执行原生 password auth。 + 服务端会根据 `login_account` 自动判断邮箱或手机号,并定位平台用户,再使用该用户的 PocketBase 原生 identity(当前为 `email`)执行原生 password auth。 登录成功后直接返回 PocketBase 原生 auth token。 requestBody: required: true @@ -426,6 +528,9 @@ paths: application/json: schema: $ref: '#/components/schemas/PlatformLoginRequest' + example: + login_account: 13509214696 + password: Momo123456 responses: '200': description: 登录成功 @@ -433,6 +538,33 @@ paths: 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': @@ -450,8 +582,6 @@ paths: description: | 基于当前 `Authorization` 对应 auth record 中的统一 `openid` 定位当前微信用户。 当前接口仍用于微信资料完善场景。 - security: - - bearerAuth: [] requestBody: required: true content: @@ -474,3 +604,187 @@ paths: description: token 无效或当前 auth record 缺少统一身份字段 openid '400': description: 参数错误、手机号已被注册或资料更新失败 + /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 + /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 + /api/dictionary/create: + post: + tags: [字典管理] + summary: 新增字典 + description: | + 仅允许 `ManagePlatform` 用户访问。 + `system_dict_id` 由服务端自动生成;`dict_name` 必须唯一; + `items` 会分别序列化写入 `dict_word_enum`、`dict_word_description`、`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: 重复请求过于频繁 + /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: 重复请求过于频繁 + /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: 重复请求过于频繁 diff --git a/script/pocketbase.js b/script/pocketbase.js index a3c3d09..ae64e00 100644 --- a/script/pocketbase.js +++ b/script/pocketbase.js @@ -15,16 +15,17 @@ const collections = [ type: 'base', fields: [ { name: 'system_dict_id', type: 'text', required: true }, - { name: 'dict_name', type: 'text' }, + { name: 'dict_name', type: 'text', required: true }, { name: 'dict_word_enum', type: 'text' }, { name: 'dict_word_description', type: 'text' }, { name: 'dict_word_is_enabled', type: 'bool' }, - { name: 'dict_word_sort_order', type: 'number' }, + { 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)' ] },