From 6490fc427fb4c602be8ce77e522821606963e20b Mon Sep 17 00:00:00 2001 From: XuJiacheng Date: Wed, 25 Mar 2026 20:03:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20PocketBase=20hooks?= =?UTF-8?q?=20=E8=AE=A4=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新微信登录、平台注册/登录、资料更新、token 刷新、认证落库等功能,统一使用 openid 作为全平台身份锚点。 - 新增平台用户注册和登录接口,支持手机号和密码认证。 - 实现系统级 token 刷新接口,支持通过微信 code 重新签发 token。 - 新增用户总数查询接口,返回 tbl_auth_users 表中的用户总数。 - 更新 OpenAPI 文档,反映新的接口和数据结构。 - 修改数据库结构,调整字段名称和索引。 - 新增页面示例,展示基本的 HTML 页面结构。 --- back-end/.env | 2 +- docs/newpb.md | 18 +- docs/tbl_auth_tables.md | 190 ++++++++++++ pocket-base/README.md | 39 ++- pocket-base/bai-api-main.pb.js | 5 +- pocket-base/bai-web-main.pb.js | 3 + .../bai_api_routes/platform/login.js | 33 +++ .../bai_api_routes/platform/register.js | 33 +++ .../bai_api_routes/system/refresh-token.js | 62 ++++ .../bai_api_routes/system/users-count.js | 30 ++ .../bai_api_routes/wechat/profile.js | 4 +- .../bai_api_routes/wechat/refresh-token.js | 10 - .../bai_api_shared/config/.env | 2 +- .../bai_api_shared/config/runtime.js | 2 +- .../middlewares/requestGuards.js | 56 +++- .../bai_api_shared/services/userService.js | 179 +++++++++++- pocket-base/bai_web_pb_hooks/pages/page-a.js | 52 ++++ ...6-03-23-pocketbase-hooks-auth-hardening.md | 46 ++- pocket-base/spec/openapi.yaml | 271 ++++++++++++++++-- script/pocketbase.newpb.js | 24 +- 20 files changed, 971 insertions(+), 90 deletions(-) create mode 100644 docs/tbl_auth_tables.md create mode 100644 pocket-base/bai-web-main.pb.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/platform/login.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/platform/register.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/system/refresh-token.js create mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/system/users-count.js delete mode 100644 pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/refresh-token.js create mode 100644 pocket-base/bai_web_pb_hooks/pages/page-a.js diff --git a/back-end/.env b/back-end/.env index e6618af..3f66e88 100644 --- a/back-end/.env +++ b/back-end/.env @@ -12,4 +12,4 @@ APP_BASE_URL=https://bai-api.blv-oa.com # Database Configuration (Pocketbase) POCKETBASE_API_URL=https://bai-api.blv-oa.com/pb/ -POCKETBASE_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDMxNTc3OSwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.PJqbHcHQ57WLYKQdycA-a96EwI5IFuKM1Mr1o-CNw_g +POCKETBASE_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTgwNTk2MzM4NywiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.R34MTotuFBpevqbbUtvQ3GPa0Z1mpY8eQwZgDdmfhMo diff --git a/docs/newpb.md b/docs/newpb.md index ca76395..9d23a2a 100644 --- a/docs/newpb.md +++ b/docs/newpb.md @@ -11,13 +11,13 @@ | 字段名 | 类型 | 说明 | | :--- | :--- | :--- | -| **user_id** | BigInt (PK) | 内部全局唯一 ID | +| **users_convers_id** | String | 会话/对话侧用户标识,允许为空 | | **openid** | String (Unique) | **全局身份锚点**,微信唯一标识 | -| **user_name** | String | 姓名/昵称 | +| **users_name** | String | 姓名/昵称 | | **org_id** | Int | 所属组织/部门 ID(影响行级权限的关键属性) | -| **rank_level** | Int | 职级/等级(影响动态权限矩阵的关键属性) | -| **status** | Int | 账户状态 (1: 正常, 0: 禁用) | -| **user_type** | Int | 账户类型 (0: 微信小程序,1: 管理平台,2: 其他) | +| **users_rank_level** | Int | 职级/等级(影响动态权限矩阵的关键属性) | +| **users_status** | Int | 账户状态 (1: 正常, 0: 禁用) | +| **users_auth_type** | Int | 账户类型 (0: 微信小程序,1: 管理平台,2: 其他) | --- @@ -47,7 +47,7 @@ | 字段名 | 类型 | 说明 | | :--- | :--- | :--- | | **id** | BigInt (PK) | 自增 ID | -| **user_id** | BigInt | 用户 ID(关联 `tbl_auth_users`) | +| **users_convers_id** | String | 用户会话标识(关联 `tbl_auth_users.users_convers_id`) | | **res_id** | Int | 资源 ID(关联 `tbl_auth_resources`) | | **access_level** | Int | 权限值 (0: 无权, 1: 只读, 2: 读写) | | **priority** | Int | 优先级(当角色权限与个人设置冲突时,以此为准) | @@ -63,7 +63,7 @@ | **target_type** | Enum | 目标:`USER` 或 `ROLE` | | **target_id** | BigInt | 对应的 UserID 或 RoleID | | **table_name** | String | 作用的表名 | -| **filter_sql** | String | 过滤逻辑。例如:`dept_id = {user.org_id}` 或 `creator_id = {user.user_id}` | +| **filter_sql** | String | 过滤逻辑。例如:`dept_id = {user.org_id}` 或 `creator_id = {user.users_convers_id}` | --- @@ -73,13 +73,13 @@ #### 1. 权限计算路径 (Effective Permissions) 当用户访问某个数据时,系统按照以下顺序合并权限: -1. **取基础属性**:获取用户的 `org_id` 和 `rank_level`。 +1. **取基础属性**:获取用户的 `org_id` 和 `users_rank_level`。 2. **取角色权限**:获取该用户所属角色对应的资源权限列表。 3. **应用个性化覆盖**:查询 `tbl_auth_user_overrides`。如果该表中有记录,则**覆盖**(或叠加)角色权限。 4. **注入行级过滤**:如果是查询操作,解析 `tbl_auth_row_scopes` 中的 `filter_sql`,将 `{user.xxx}` 变量替换为当前用户的真实值。 #### 2. 动态更新机制 -* **组织/等级变更**:当 `tbl_auth_users` 中的 `org_id` 或 `rank_level` 变化时,由于行级过滤表(`tbl_auth_row_scopes`)引用的是动态变量,**权限会自动生效**,无需重新授权。 +* **组织/等级变更**:当 `tbl_auth_users` 中的 `org_id` 或 `users_rank_level` 变化时,由于行级过滤表(`tbl_auth_row_scopes`)引用的是动态变量,**权限会自动生效**,无需重新授权。 * **缓存策略**:建议将计算后的“最终权限清单”缓存到 Redis。当用户在后台矩阵页面修改权限,或者发生组织架构调整时,通过 `wx_openid` **主动失效(Purge)** 该用户的 Redis 缓存。 #### 3. 字段级权限实现 (Field-Level) diff --git a/docs/tbl_auth_tables.md b/docs/tbl_auth_tables.md new file mode 100644 index 0000000..501253f --- /dev/null +++ b/docs/tbl_auth_tables.md @@ -0,0 +1,190 @@ +# tbl_auth 系列表结构说明 + +> 数据来源:`script/pocketbase.newpb.js` +> +> 说明:以下为当前脚本中定义的 `tbl_auth_` 系列 PocketBase 集合结构,不含 PocketBase `auth` 集合的系统内置字段,仅列出脚本中显式声明的自定义字段与索引。 + +--- + +## 1. `tbl_auth_users` + +- 集合类型:`auth` +- 说明:认证用户主表,业务身份锚点为 `openid` + +### 字段 + +| 字段名 | 类型 | 必填 | 备注 | +| :--- | :--- | :---: | :--- | +| `users_convers_id` | `text` | 否 | 会话/对话侧用户 ID | +| `openid` | `text` | 是 | 微信身份锚点,唯一索引 | +| `org_id` | `number` | 否 | 组织/部门 ID | +| `users_rank_level` | `number` | 否 | 等级 | +| `users_status` | `number` | 否 | 状态 | +| `users_auth_type` | `number` | 否 | 账户类型 | +| `users_id` | `text` | 否 | 自定义用户 ID | +| `users_name` | `text` | 否 | 用户姓名 | +| `users_idtype` | `text` | 否 | 证件类型 | +| `users_id_number` | `text` | 否 | 证件号 | +| `users_phone` | `text` | 否 | 手机号 | +| `users_level` | `text` | 否 | 用户等级 | +| `users_type` | `text` | 否 | 用户类型 | +| `users_status` | `text` | 否 | 用户状态 | +| `company_id` | `text` | 否 | 公司 ID | +| `users_parent_id` | `text` | 否 | 上级用户 ID | +| `users_promo_code` | `text` | 否 | 推广码 | +| `users_id_pic_a` | `file` | 否 | 证件照正面,`maxSelect: 1`,允许 `jpeg/png/webp` | +| `users_id_pic_b` | `file` | 否 | 证件照反面,`maxSelect: 1`,允许 `jpeg/png/webp` | +| `users_title_picture` | `file` | 否 | 资质照片,`maxSelect: 1`,允许 `jpeg/png/webp` | +| `users_picture` | `text` | 否 | 用户头像 | +| `usergroups_id` | `text` | 否 | 用户组 ID | + +### 索引 + +| 索引名 | 类型 | 字段 | +| :--- | :--- | :--- | +| `idx_tbl_auth_users_users_convers_id` | UNIQUE INDEX | `users_convers_id` | +| `idx_tbl_auth_users_openid` | UNIQUE INDEX | `openid` | +| `idx_tbl_auth_users_org_id` | INDEX | `org_id` | +| `idx_tbl_auth_users_users_rank_level` | INDEX | `users_rank_level` | +| `idx_tbl_auth_users_users_status` | INDEX | `users_status` | +| `idx_tbl_auth_users_users_auth_type` | INDEX | `users_auth_type` | +| `idx_tbl_auth_users_users_phone` | INDEX | `users_phone` | +| `idx_tbl_auth_users_company_id` | INDEX | `company_id` | +| `idx_tbl_auth_users_usergroups_id` | INDEX | `usergroups_id` | +| `idx_tbl_auth_users_users_parent_id` | INDEX | `users_parent_id` | + +--- + +## 2. `tbl_auth_resources` + +- 集合类型:`base` +- 说明:受控资源定义表 + +### 字段 + +| 字段名 | 类型 | 必填 | 备注 | +| :--- | :--- | :---: | :--- | +| `res_id` | `text` | 是 | 资源 ID | +| `table_name` | `text` | 是 | 表名 | +| `column_name` | `text` | 否 | 字段名 | +| `res_type` | `text` | 是 | 资源类型 | + +### 索引 + +| 索引名 | 类型 | 字段 | +| :--- | :--- | :--- | +| `idx_tbl_auth_resources_res_id` | UNIQUE INDEX | `res_id` | +| `idx_tbl_auth_resources_table_name` | INDEX | `table_name` | +| `idx_tbl_auth_resources_res_type` | INDEX | `res_type` | +| `idx_tbl_auth_resources_unique_res` | UNIQUE INDEX | `table_name, column_name, res_type` | + +--- + +## 3. `tbl_auth_roles` + +- 集合类型:`base` +- 说明:角色定义表 + +### 字段 + +| 字段名 | 类型 | 必填 | 备注 | +| :--- | :--- | :---: | :--- | +| `role_id` | `text` | 是 | 角色 ID | +| `role_name` | `text` | 是 | 角色名称 | +| `role_code` | `text` | 否 | 角色编码 | +| `role_status` | `number` | 否 | 角色状态 | +| `role_remark` | `text` | 否 | 备注 | + +### 索引 + +| 索引名 | 类型 | 字段 | +| :--- | :--- | :--- | +| `idx_tbl_auth_roles_role_id` | UNIQUE INDEX | `role_id` | +| `idx_tbl_auth_roles_role_name` | UNIQUE INDEX | `role_name` | +| `idx_tbl_auth_roles_role_code` | UNIQUE INDEX | `role_code` | + +--- + +## 4. `tbl_auth_role_perms` + +- 集合类型:`base` +- 说明:角色权限映射表 + +### 字段 + +| 字段名 | 类型 | 必填 | 备注 | +| :--- | :--- | :---: | :--- | +| `role_perm_id` | `text` | 是 | 角色权限记录 ID | +| `role_id` | `text` | 是 | 角色 ID | +| `res_id` | `text` | 是 | 资源 ID | +| `access_level` | `number` | 是 | 权限级别 | +| `priority` | `number` | 否 | 优先级 | + +### 索引 + +| 索引名 | 类型 | 字段 | +| :--- | :--- | :--- | +| `idx_tbl_auth_role_perms_role_perm_id` | UNIQUE INDEX | `role_perm_id` | +| `idx_tbl_auth_role_perms_role_id` | INDEX | `role_id` | +| `idx_tbl_auth_role_perms_res_id` | INDEX | `res_id` | +| `idx_tbl_auth_role_perms_unique_map` | UNIQUE INDEX | `role_id, res_id` | + +--- + +## 5. `tbl_auth_user_overrides` + +- 集合类型:`base` +- 说明:用户个性化权限覆盖表 + +### 字段 + +| 字段名 | 类型 | 必填 | 备注 | +| :--- | :--- | :---: | :--- | +| `override_id` | `text` | 是 | 覆盖记录 ID | +| `users_convers_id` | `text` | 是 | 用户会话标识 | +| `res_id` | `text` | 是 | 资源 ID | +| `access_level` | `number` | 是 | 权限级别 | +| `priority` | `number` | 否 | 优先级 | + +### 索引 + +| 索引名 | 类型 | 字段 | +| :--- | :--- | :--- | +| `idx_tbl_auth_user_overrides_override_id` | UNIQUE INDEX | `override_id` | +| `idx_tbl_auth_user_overrides_users_convers_id` | INDEX | `users_convers_id` | +| `idx_tbl_auth_user_overrides_res_id` | INDEX | `res_id` | +| `idx_tbl_auth_user_overrides_unique_map` | UNIQUE INDEX | `users_convers_id, res_id` | + +--- + +## 6. `tbl_auth_row_scopes` + +- 集合类型:`base` +- 说明:行级权限范围表 + +### 字段 + +| 字段名 | 类型 | 必填 | 备注 | +| :--- | :--- | :---: | :--- | +| `scope_id` | `text` | 是 | 范围记录 ID | +| `target_type` | `text` | 是 | 目标类型 | +| `target_id` | `text` | 是 | 目标 ID | +| `table_name` | `text` | 是 | 作用表名 | +| `filter_sql` | `editor` | 是 | 行级过滤表达式 | + +### 索引 + +| 索引名 | 类型 | 字段 | +| :--- | :--- | :--- | +| `idx_tbl_auth_row_scopes_scope_id` | UNIQUE INDEX | `scope_id` | +| `idx_tbl_auth_row_scopes_target_type` | INDEX | `target_type` | +| `idx_tbl_auth_row_scopes_target_id` | INDEX | `target_id` | +| `idx_tbl_auth_row_scopes_table_name` | INDEX | `table_name` | + +--- + +## 备注 + +1. `tbl_auth_users` 是 `auth` 集合,除上表字段外,还会受 PocketBase auth 系统字段与认证配置影响。 +2. 当前文档仅以 `script/pocketbase.newpb.js` 为准,不代表线上数据库已经 100% 同步成功。 +3. 若你需要,我可以继续帮你再生成一份“更像数据库设计说明书”的版本,增加字段含义、业务用途、关联关系三列。 \ No newline at end of file diff --git a/pocket-base/README.md b/pocket-base/README.md index d3c6856..c8bbc6a 100644 --- a/pocket-base/README.md +++ b/pocket-base/README.md @@ -31,9 +31,11 @@ pocket-base/ - `POST /api/system/test-helloworld` - `POST /api/system/health` +- `POST /api/system/refresh-token` +- `POST /api/platform/register` +- `POST /api/platform/login` - `POST /api/wechat/login` - `POST /api/wechat/profile` -- `POST /api/wechat/refresh-token` > 当前自定义路由统一使用 `/api/...` 前缀。 @@ -43,6 +45,37 @@ pocket-base/ - `Open-Authorization` 不是本项目接口定义的 Header,如调试工具里出现,通常是工具全局预设,应删除。 - `users_wx_openid` Header 已移除,不再需要客户端额外传递。 - 当前用户身份以 PocketBase auth record 中的 `openid` 字段为准。 +- `openid` 现已定义为**全平台统一身份锚点**: + - 微信用户:`openid = 微信 openid` + - 平台用户:`openid = 服务端生成的 GUID` +- 当前登录/注册成功后返回的 `token` 为 PocketBase 原生 auth token,可直接用于 PocketBase SDK 与本项目 hooks 接口调用。 + +## 平台用户与微信用户说明 + +### 平台用户 + +- 注册接口:`POST /api/platform/register` +- 登录接口:`POST /api/platform/login` +- 平台用户注册时会自动生成 GUID 并写入 `tbl_auth_users.openid` +- 同时写入 `users_idtype = ManagePlatform` +- 平台登录对前端暴露为 `users_phone + password` +- 服务端内部仍使用 PocketBase 原生 password auth,以确保返回原生 `token` + +### 通用认证能力 + +- 刷新 token 接口:`POST /api/system/refresh-token` +- 该接口属于系统级通用认证接口,不区分微信用户或平台用户 +- body 中 `users_wx_code` 允许为空 +- 服务端会优先验证 `Authorization`:若 token 仍有效,直接续签,不调用微信接口 +- 仅当 token 失效时,且提供了 `users_wx_code`,才走微信 code 重签流程(逻辑独立于 `/api/wechat/login` 的完整返回) +- 若 token 失效且未提供 `users_wx_code`,返回:`token已过期,请上传users_wx_code` +- 返回体为精简结构,仅返回新 `token` + +### 微信用户 + +- 登录/注册接口:`POST /api/wechat/login` +- 微信用户以微信 code 换取微信侧 openid,并写入统一 `tbl_auth_users.openid` +- 首次注册时写入 `users_idtype = WeChat` ## 部署方式 @@ -126,10 +159,12 @@ PocketBase JSVM 不是 Node.js 运行时: 本次变更重点包括: - 微信登录链路错误显式返回 +- 平台用户注册/登录接口补充完成 +- 刷新 token 接口调整到 system 分类 - `recordAuthResponse` 使用空 `authMethod` - active hooks 中移除 `-created` 排序 - `users_phone` 索引由唯一改为普通索引 -- `tbl_auth_users` 以 `openid` 为业务身份锚点 +- `tbl_auth_users` 以全平台统一 `openid` 为业务身份锚点 - auth 集合兼容占位 `email`、随机密码与 `passwordConfirm` ## 与原项目关系 diff --git a/pocket-base/bai-api-main.pb.js b/pocket-base/bai-api-main.pb.js index 68e95da..dda3fae 100644 --- a/pocket-base/bai-api-main.pb.js +++ b/pocket-base/bai-api-main.pb.js @@ -19,6 +19,9 @@ routerUse(function (e) { require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/system/hello.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/system/health.js`) +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/wechat/login.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/profile.js`) -require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/refresh-token.js`) diff --git a/pocket-base/bai-web-main.pb.js b/pocket-base/bai-web-main.pb.js new file mode 100644 index 0000000..8f1d652 --- /dev/null +++ b/pocket-base/bai-web-main.pb.js @@ -0,0 +1,3 @@ +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`) 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 new file mode 100644 index 0000000..05cbc27 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/platform/login.js @@ -0,0 +1,33 @@ +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`) + + try { + guards.requireJson(e) + guards.duplicateGuard(e) + + const payload = guards.validatePlatformLoginBody(e) + const data = userService.authenticatePlatformUser(payload) + + $apis.recordAuthResponse(e, data.authRecord, data.authMethod, data.meta) + return + } catch (err) { + const status = + (err && typeof err.statusCode === 'number' && err.statusCode) + || (err && typeof err.status === 'number' && 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/register.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/platform/register.js new file mode 100644 index 0000000..df9b597 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/platform/register.js @@ -0,0 +1,33 @@ +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`) + + try { + guards.requireJson(e) + guards.duplicateGuard(e) + + const payload = guards.validatePlatformRegisterBody(e) + const data = userService.registerPlatformUser(payload) + + $apis.recordAuthResponse(e, data.authRecord, data.authMethod, data.meta) + return + } catch (err) { + const status = + (err && typeof err.statusCode === 'number' && err.statusCode) + || (err && typeof err.status === 'number' && 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/system/refresh-token.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/refresh-token.js new file mode 100644 index 0000000..a5415cd --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/refresh-token.js @@ -0,0 +1,62 @@ +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`) + + try { + guards.requireJson(e) + guards.duplicateGuard(e) + + const payload = guards.validateSystemRefreshBody(e) + + if (e.auth && e.auth.collection().name === 'tbl_auth_users' && e.auth.getString('openid')) { + const openid = e.auth.getString('openid') + const data = userService.refreshAuthToken(openid) + const token = userService.issueAuthToken(data.authRecord) + + return e.json(200, { + code: 200, + msg: '刷新成功', + data: { + token: token, + }, + }) + } + + if (!payload.users_wx_code) { + return e.json(401, { + code: 401, + msg: 'token已过期,请上传users_wx_code', + data: {}, + }) + } + + const authData = userService.authenticateWechatUser(payload) + const token = userService.issueAuthToken(authData.authRecord) + + return e.json(200, { + code: 200, + msg: '刷新成功', + data: { + token: token, + }, + }) + } catch (err) { + const status = + (err && typeof err.statusCode === 'number' && err.statusCode) + || (err && typeof err.status === 'number' && 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/system/users-count.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/users-count.js new file mode 100644 index 0000000..1bf2ec4 --- /dev/null +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/system/users-count.js @@ -0,0 +1,30 @@ +routerAdd('POST', '/api/system/users-count', function (e) { + const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) + + const pageSize = 500 + let page = 1 + let total = 0 + + while (true) { + const records = $app.findRecordsByFilter( + 'tbl_auth_users', + '', + '', + pageSize, + (page - 1) * pageSize + ) + + const count = Array.isArray(records) ? records.length : 0 + total += count + + if (count < pageSize) { + break + } + + page += 1 + } + + return success(e, '查询成功', { + total_users: total, + }) +}) \ No newline at end of file diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/profile.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/profile.js index 7459f6b..b03feb8 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/profile.js +++ b/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/profile.js @@ -4,11 +4,11 @@ routerAdd('POST', '/api/wechat/profile', function (e) { const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) guards.requireJson(e) - const authState = guards.requireWechatAuth(e) + const authState = guards.requireAuthUser(e) guards.duplicateGuard(e) const payload = guards.validateProfileBody(e) - const data = userService.updateWechatUserProfile(authState.usersWxOpenid, payload) + const data = userService.updateWechatUserProfile(authState.openid, payload) return success(e, '信息更新成功', data) }) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/refresh-token.js b/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/refresh-token.js deleted file mode 100644 index 3aff8b4..0000000 --- a/pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/refresh-token.js +++ /dev/null @@ -1,10 +0,0 @@ -routerAdd('POST', '/api/wechat/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 usersWxOpenid = guards.requireWechatOpenid(e) - const data = userService.refreshWechatToken(usersWxOpenid) - - $apis.recordAuthResponse(e, data.authRecord, data.authMethod, data.meta) - return -}) diff --git a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/.env b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/.env index d658c7c..4e78de5 100644 --- a/pocket-base/bai_api_pb_hooks/bai_api_shared/config/.env +++ b/pocket-base/bai_api_pb_hooks/bai_api_shared/config/.env @@ -1,7 +1,7 @@ NODE_ENV=production APP_BASE_URL=https://bai-api.blv-oa.com POCKETBASE_API_URL=https://bai-api.blv-oa.com/pb/ -POCKETBASE_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo +POCKETBASE_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTgwNTk2MzM4NywiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.R34MTotuFBpevqbbUtvQ3GPa0Z1mpY8eQwZgDdmfhMo #正式服 #WECHAT_APPID=wx3bd7a7b19679da7a 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 d5aea55..729da21 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 @@ -2,7 +2,7 @@ module.exports = { NODE_ENV: 'production', APP_BASE_URL: 'https://bai-api.blv-oa.com', POCKETBASE_API_URL: 'https://bai-api.blv-oa.com/pb/', - POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo', + POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTgwNTk2MzM4NywiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.R34MTotuFBpevqbbUtvQ3GPa0Z1mpY8eQwZgDdmfhMo', WECHAT_APPID: 'wx3bd7a7b19679da7a', WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1', /* WECHAT_APPID: 'wx42e9add0f91af98b', 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 775c275..f0d3347 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 @@ -31,7 +31,50 @@ function validateProfileBody(e) { return payload } -function requireWechatOpenid(e) { +function validatePlatformRegisterBody(e) { + const payload = parseBody(e) + + if (!payload.users_name) throw createAppError(400, 'users_name 为必填项') + if (!payload.users_phone) throw createAppError(400, 'users_phone 为必填项') + if (!payload.password) throw createAppError(400, 'password 为必填项') + if (!payload.passwordConfirm) throw createAppError(400, 'passwordConfirm 为必填项') + if (!payload.users_picture) throw createAppError(400, 'users_picture 为必填项') + + if (payload.password !== payload.passwordConfirm) { + throw createAppError(400, 'password 与 passwordConfirm 不一致') + } + + return payload +} + +function validatePlatformLoginBody(e) { + const payload = parseBody(e) + + if (!payload.users_phone) throw createAppError(400, 'users_phone 为必填项') + if (!payload.password) throw createAppError(400, 'password 为必填项') + + return payload +} + +function validateSystemRefreshBody(e) { + const payload = parseBody(e) + + if (typeof payload.users_wx_code === 'undefined' || payload.users_wx_code === null) { + return { + users_wx_code: '', + } + } + + if (typeof payload.users_wx_code !== 'string') { + throw createAppError(400, 'users_wx_code 类型错误') + } + + return { + users_wx_code: payload.users_wx_code, + } +} + +function requireAuthOpenid(e) { if (!e.auth) { throw createAppError(401, '认证令牌无效或已过期') } @@ -48,7 +91,7 @@ function requireWechatOpenid(e) { return openid } -function requireWechatAuth(e) { +function requireAuthUser(e) { const authHeader = e.request.header.get('Authorization') || '' const parts = authHeader.split(' ') @@ -70,7 +113,7 @@ function requireWechatAuth(e) { } return { - usersWxOpenid: authOpenid, + openid: authOpenid, authRecord: e.auth, } } @@ -92,7 +135,10 @@ module.exports = { requireJson, validateLoginBody, validateProfileBody, - requireWechatOpenid, - requireWechatAuth, + validatePlatformRegisterBody, + validatePlatformLoginBody, + validateSystemRefreshBody, + requireAuthOpenid, + requireAuthUser, duplicateGuard, } 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 ae5796d..55118f2 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 @@ -4,6 +4,8 @@ const wechatService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/servic const GUEST_USER_TYPE = '游客' const REGISTERED_USER_TYPE = '注册用户' +const WECHAT_ID_TYPE = 'WeChat' +const MANAGE_PLATFORM_ID_TYPE = 'ManagePlatform' const mutationLocks = {} function buildUserId() { @@ -16,6 +18,10 @@ function buildUserId() { return 'U' + date + suffix } +function buildGuid() { + return $security.randomString(8) + '-' + $security.randomString(4) + '-' + $security.randomString(4) + '-' + $security.randomString(4) + '-' + $security.randomString(12) +} + function maskPhone(phone) { const value = phone || '' if (!value || value.length < 7) return '' @@ -51,6 +57,13 @@ function findUserByOpenid(usersWxOpenid) { return records.length ? records[0] : null } +function findUserByPhone(usersPhone) { + const records = $app.findRecordsByFilter('tbl_auth_users', 'users_phone = {:phone}', '', 1, 0, { + phone: usersPhone, + }) + return records.length ? records[0] : null +} + function getCompanyByCompanyId(companyId) { if (!companyId) return null const records = $app.findRecordsByFilter('tbl_company', 'company_id = {:companyId}', '', 1, 0, { @@ -71,14 +84,24 @@ function enrichUser(userRecord) { return { pb_id: userRecord.id, - users_id: userRecord.getString('users_id') || userRecord.getString('user_id'), + users_convers_id: userRecord.getString('users_convers_id'), + users_id: userRecord.getString('users_id'), + users_idtype: userRecord.getString('users_idtype'), + users_id_number: userRecord.getString('users_id_number'), users_type: userRecord.getString('users_type') || GUEST_USER_TYPE, - users_name: userRecord.getString('users_name') || userRecord.getString('user_name'), + users_name: userRecord.getString('users_name'), + users_status: userRecord.get('users_status'), + users_rank_level: userRecord.get('users_rank_level'), + users_auth_type: userRecord.get('users_auth_type'), users_phone: userRecord.getString('users_phone'), users_phone_masked: maskPhone(userRecord.getString('users_phone')), + users_level: userRecord.getString('users_level'), users_picture: userRecord.getString('users_picture'), openid: openid, company_id: companyId || '', + users_parent_id: userRecord.getString('users_parent_id'), + users_promo_code: userRecord.getString('users_promo_code'), + usergroups_id: userRecord.getString('usergroups_id'), company: exportCompany(companyRecord), created: String(userRecord.created || ''), updated: String(userRecord.updated || ''), @@ -114,11 +137,17 @@ function ensureAuthIdentity(record) { } } +function ensureUserId(record) { + if (!record.getString('users_id')) { + record.set('users_id', buildUserId()) + } +} + function saveAuthUserRecord(record) { try { $app.save(record) } catch (err) { - throw createAppError(400, '保存微信用户失败', { + throw createAppError(400, '保存认证用户失败', { originalMessage: (err && err.message) || '未知错误', originalData: (err && err.data) || {}, }) @@ -154,11 +183,11 @@ function authenticateWechatUser(payload) { const collection = $app.findCollectionByNameOrId('tbl_auth_users') const record = new Record(collection) - record.set('user_id', buildUserId()) - record.set('users_id', record.getString('user_id')) record.set('openid', openid) + ensureUserId(record) + record.set('users_idtype', WECHAT_ID_TYPE) record.set('users_type', GUEST_USER_TYPE) - record.set('user_auth_type', 0) + record.set('users_auth_type', 0) ensureAuthIdentity(record) saveAuthUserRecord(record) @@ -184,6 +213,112 @@ function authenticateWechatUser(payload) { }) } +function registerPlatformUser(payload) { + return withUserLock('platform-register:' + payload.users_phone, function () { + const existingPhoneUsers = $app.findRecordsByFilter('tbl_auth_users', 'users_phone = {:phone}', '', 10, 0, { + phone: payload.users_phone, + }) + + for (let i = 0; i < existingPhoneUsers.length; i += 1) { + if (existingPhoneUsers[i].getString('users_phone') === payload.users_phone) { + throw createAppError(400, '手机号已被注册') + } + } + + const platformOpenid = buildGuid() + const existingOpenid = findUserByOpenid(platformOpenid) + if (existingOpenid) { + throw createAppError(500, '生成平台用户唯一标识失败,请稍后重试') + } + + const collection = $app.findCollectionByNameOrId('tbl_auth_users') + const record = new Record(collection) + + record.set('openid', platformOpenid) + ensureUserId(record) + record.set('users_idtype', MANAGE_PLATFORM_ID_TYPE) + record.set('users_name', payload.users_name) + record.set('users_phone', payload.users_phone) + record.set('users_picture', payload.users_picture) + record.set('users_id_number', payload.users_id_number || '') + record.set('users_level', payload.users_level || '') + record.set('users_type', payload.users_type || REGISTERED_USER_TYPE) + record.set('company_id', payload.company_id || '') + record.set('users_parent_id', payload.users_parent_id || '') + 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.setPassword(payload.password) + record.set('passwordConfirm', payload.passwordConfirm) + + saveAuthUserRecord(record) + + const user = enrichUser(record) + + logger.info('平台注册用户成功', { + users_id: user.users_id, + openid: user.openid, + users_idtype: user.users_idtype, + }) + + return { + status: 'register_success', + is_info_complete: isInfoComplete(record), + user: user, + authRecord: record, + authMethod: '', + meta: buildAuthMeta({ + status: 'register_success', + is_info_complete: isInfoComplete(record), + user: user, + }), + } + }) +} + +function authenticatePlatformUser(payload) { + return withUserLock('platform-login:' + payload.users_phone, function () { + const userRecord = findUserByPhone(payload.users_phone) + if (!userRecord) { + throw createAppError(404, '平台用户不存在') + } + + if (userRecord.getString('users_idtype') !== MANAGE_PLATFORM_ID_TYPE) { + throw createAppError(400, '当前手机号对应的不是平台用户') + } + + const identity = userRecord.getString('email') + if (!identity) { + throw createAppError(500, '平台用户缺少原生登录标识') + } + + const authData = $apis.recordAuthWithPassword('tbl_auth_users', identity, payload.password) + const authRecord = authData.record || userRecord + + const user = enrichUser(authRecord) + + logger.info('平台用户登录成功', { + users_id: user.users_id, + openid: user.openid, + users_idtype: user.users_idtype, + }) + + return { + status: 'login_success', + is_info_complete: isInfoComplete(authRecord), + user: user, + authRecord: authRecord, + authMethod: '', + meta: buildAuthMeta({ + status: 'login_success', + is_info_complete: isInfoComplete(authRecord), + user: user, + }), + } + }) +} + function updateWechatUserProfile(usersWxOpenid, payload) { return withUserLock('profile:' + usersWxOpenid, function () { const currentUser = findUserByOpenid(usersWxOpenid) @@ -211,7 +346,6 @@ function updateWechatUserProfile(usersWxOpenid, payload) { && ((currentUser.getString('users_type') || GUEST_USER_TYPE) === GUEST_USER_TYPE) currentUser.set('users_name', payload.users_name) - currentUser.set('user_name', payload.users_name) currentUser.set('users_phone', usersPhone) currentUser.set('users_picture', payload.users_picture) if (shouldPromote) { @@ -235,14 +369,15 @@ function updateWechatUserProfile(usersWxOpenid, payload) { }) } -function refreshWechatToken(usersWxOpenid) { - const userRecord = findUserByOpenid(usersWxOpenid) +function refreshAuthToken(openid) { + const userRecord = findUserByOpenid(openid) if (!userRecord) { throw createAppError(404, '未注册用户') } - logger.info('微信用户刷新令牌成功', { - users_id: userRecord.getString('users_id') || userRecord.getString('user_id'), + logger.info('认证用户刷新令牌成功', { + users_id: userRecord.getString('users_id'), + users_convers_id: userRecord.getString('users_convers_id'), openid: userRecord.getString('openid'), }) @@ -257,8 +392,28 @@ function refreshWechatToken(usersWxOpenid) { } } +function issueAuthToken(authRecord) { + if (!authRecord) { + throw createAppError(500, '签发令牌失败:认证用户不存在') + } + + if (typeof authRecord.newAuthToken !== 'function') { + throw createAppError(500, '签发令牌失败:当前运行环境不支持 newAuthToken') + } + + const token = authRecord.newAuthToken() + if (!token) { + throw createAppError(500, '签发令牌失败:生成 token 为空') + } + + return token +} + module.exports = { authenticateWechatUser, + authenticatePlatformUser, updateWechatUserProfile, - refreshWechatToken, + refreshAuthToken, + issueAuthToken, + registerPlatformUser, } diff --git a/pocket-base/bai_web_pb_hooks/pages/page-a.js b/pocket-base/bai_web_pb_hooks/pages/page-a.js new file mode 100644 index 0000000..b591dfb --- /dev/null +++ b/pocket-base/bai_web_pb_hooks/pages/page-a.js @@ -0,0 +1,52 @@ +routerAdd('GET', '/web/page-a', function (e) { + const html = ` + + + + + 页面一 + + + +
+
+

页面一

+

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

+ +
+
+ +` + + return e.html(200, html) +}) 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 e6540a9..818adfd 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 刷新、认证落库、错误可观测性、索引策略与运行规范。 --- @@ -30,7 +30,9 @@ ### 1. openid 作为唯一业务身份锚点 -- `tbl_auth_users` 仅保留 `openid` 作为微信身份锚点。 +- `tbl_auth_users` 统一保留 `openid` 作为全平台身份锚点。 +- 微信用户:`openid = 微信 openid` +- 平台用户:`openid = 服务端生成的 GUID` - 业务逻辑中不再使用 `users_wx_openid`。 - 用户查询、token 刷新、资料更新均基于 auth record 的 `openid`。 @@ -38,7 +40,9 @@ 由于 `tbl_auth_users` 当前为 PocketBase `auth` 集合,登录注册时为兼容 PocketBase 原生 auth 校验,新增以下兼容策略: -- `email` 使用占位格式:`@wechat.local` +- `email` 使用占位格式: + - 微信用户:`@wechat.local` + - 平台用户:`@manage.local` - 自动生成随机密码 - 自动补齐 `passwordConfirm` @@ -92,7 +96,7 @@ 新增 `saveAuthUserRecord(record)` 包装 `$app.save(record)`: -- 失败时统一抛出 `保存微信用户失败` +- 失败时统一抛出 `保存认证用户失败` - 附带 `originalMessage` 与 `originalData` 目的: @@ -130,17 +134,36 @@ - `POST /api/system/test-helloworld` - `POST /api/system/health` +- `POST /api/system/refresh-token` +- `POST /api/platform/register` +- `POST /api/platform/login` - `POST /api/wechat/login` - `POST /api/wechat/profile` -- `POST /api/wechat/refresh-token` + +其中平台用户链路补充为: + +### `POST /api/platform/register` + +- body 必填:`users_name`、`users_phone`、`password`、`passwordConfirm`、`users_picture` +- 自动生成 GUID 并写入统一身份字段 `openid` +- 写入 `users_idtype = ManagePlatform` +- 成功时返回 PocketBase 原生 token + auth record + meta + +### `POST /api/platform/login` + +- body 必填:`users_phone`、`password` +- 仅允许 `users_idtype = ManagePlatform` +- 前端使用手机号+密码提交 +- 服务端内部仍通过 PocketBase 原生 password auth 返回原生 token 其中: ### `POST /api/wechat/login` - body 必填:`users_wx_code` -- 自动以微信 code 换取 `openid` +- 自动以微信 code 换取微信侧 `openid` 并写入统一身份字段 - 若不存在 auth 用户则尝试创建 `tbl_auth_users` 记录 +- 写入 `users_idtype = WeChat` - 成功时返回 PocketBase 原生 token + auth record + meta ### `POST /api/wechat/profile` @@ -149,10 +172,15 @@ - 基于当前 auth record 的 `openid` 定位用户 - 服务端用 `users_phone_code` 换取手机号后保存 -### `POST /api/wechat/refresh-token` +### `POST /api/system/refresh-token` -- 需 `Authorization` -- 直接基于当前 auth record 返回新的 PocketBase 原生 token +- body 可选:`users_wx_code`(允许为空) +- `Authorization` 可选: + - 若 token 仍有效:基于当前 auth record 续签 + - 若 token 已过期:回退到微信 code 重签流程 +- 若 token 已过期且未提供 `users_wx_code`,返回:`token已过期,请上传users_wx_code` +- 返回精简结构,仅返回新 token(不返回完整登录用户信息) +- 属于系统级通用认证能力,不限定为微信专属接口 --- diff --git a/pocket-base/spec/openapi.yaml b/pocket-base/spec/openapi.yaml index 73ab951..6101825 100644 --- a/pocket-base/spec/openapi.yaml +++ b/pocket-base/spec/openapi.yaml @@ -1,7 +1,11 @@ openapi: 3.1.0 info: title: BAI PocketBase Hooks API - description: 基于 PocketBase `bai_api_pb_hooks` 的对外接口文档,可直接导入 Postman。 + description: | + 基于 PocketBase `bai_api_pb_hooks` 的对外接口文档,可直接导入 Postman。 + 当前 `tbl_auth_users.openid` 已被定义为全平台统一身份锚点: + - 微信用户:`openid = 微信 openid` + - 平台用户:`openid = 服务端生成的 GUID` version: 1.0.0 servers: - url: https://bai-api.blv-oa.com/pb @@ -12,7 +16,9 @@ tags: - name: 系统 description: 基础检查接口 - name: 微信认证 - description: 基于微信 openid 与 PocketBase 原生 token 的认证接口 + description: 面向微信用户的认证接口;认证成功后仍统一使用全平台 `openid` 与 PocketBase 原生 token。 + - name: 平台认证 + description: 面向平台用户的认证接口;平台用户会生成 GUID 并写入统一 `openid` 字段。 components: securitySchemes: bearerAuth: @@ -42,6 +48,13 @@ components: timestamp: type: string format: date-time + UsersCountData: + type: object + properties: + total_users: + type: integer + description: tbl_auth_users 表中的用户总数 + example: 128 HelloWorldData: type: object properties: @@ -64,11 +77,28 @@ components: additionalProperties: true UserInfo: type: object + description: | + 统一用户视图。 + 其中 `openid` 为全平台统一身份标识:微信用户使用微信 openid,平台用户使用服务端生成 GUID。 properties: pb_id: type: string + users_convers_id: + type: string users_id: type: string + users_idtype: + type: string + description: 用户身份来源类型 + enum: [WeChat, ManagePlatform] + users_id_number: + type: string + users_status: + type: number + users_rank_level: + type: number + users_auth_type: + type: number users_type: type: string enum: [游客, 注册用户] @@ -78,12 +108,21 @@ components: type: string users_phone_masked: type: string + users_level: + type: string users_picture: type: string openid: type: string + description: 全平台统一身份标识;微信用户为微信 openid,平台用户为服务端生成的 GUID company_id: type: string + users_parent_id: + type: string + users_promo_code: + type: string + usergroups_id: + type: string company: $ref: '#/components/schemas/CompanyInfo' created: @@ -92,6 +131,9 @@ components: type: string PocketBaseAuthResponse: type: object + description: | + PocketBase 原生认证响应。 + 客户端可直接使用返回的 `token` 与 PocketBase SDK 或当前 hooks 接口交互。 properties: token: type: string @@ -121,6 +163,7 @@ components: WechatLoginRequest: type: object required: [users_wx_code] + description: 微信小程序登录/注册请求体。 properties: users_wx_code: type: string @@ -129,6 +172,7 @@ components: WechatProfileRequest: type: object required: [users_name, users_phone_code, users_picture] + description: 微信用户资料完善请求体。 properties: users_name: type: string @@ -147,6 +191,70 @@ components: enum: [update_success] user: $ref: '#/components/schemas/UserInfo' + PlatformRegisterRequest: + type: object + required: [users_name, users_phone, password, passwordConfirm, users_picture] + description: 平台用户注册请求体;注册成功后将生成 GUID 并写入统一 `openid` 字段。 + properties: + users_name: + type: string + example: 张三 + users_phone: + type: string + example: 13800138000 + password: + type: string + example: 12345678 + passwordConfirm: + type: string + example: 12345678 + users_picture: + type: string + example: https://example.com/avatar.png + users_id_number: + type: string + users_level: + type: string + users_type: + type: string + company_id: + type: string + users_parent_id: + type: string + users_promo_code: + type: string + usergroups_id: + type: string + PlatformLoginRequest: + type: object + required: [users_phone, password] + description: 平台用户登录请求体;前端使用手机号+密码提交,服务端内部转换为 PocketBase 原生 password auth。 + properties: + users_phone: + type: string + example: 13800138000 + password: + type: string + example: 12345678 + SystemRefreshTokenRequest: + type: object + description: | + 系统刷新 token 请求体。 + `users_wx_code` 允许为空。 + 当 `Authorization` 对应 token 有效时,可不传或传空; + 当 token 失效时,需提供 `users_wx_code` 走微信 code 重新签发流程。 + properties: + users_wx_code: + type: string + nullable: true + description: 微信小程序登录临时凭证 code + example: 0a1b2c3d4e5f6g + RefreshTokenData: + type: object + properties: + token: + type: string + description: 新签发的 PocketBase 原生 auth token paths: /api/system/test-helloworld: post: @@ -180,13 +288,76 @@ paths: properties: data: $ref: '#/components/schemas/HealthData' + /api/system/users-count: + post: + tags: [系统] + summary: 查询用户总数 + description: 统计 `tbl_auth_users` 集合中的记录总数,并返回一个数值。 + responses: + '200': + description: 成功 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/UsersCountData' + /api/system/refresh-token: + post: + tags: [系统] + summary: 刷新系统认证 token + description: | + 当前实现支持两种刷新路径: + 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: + application/json: + schema: + $ref: '#/components/schemas/SystemRefreshTokenRequest' + responses: + '200': + description: 刷新成功(返回精简 token) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/RefreshTokenData' + '400': + description: 参数错误或微信侧身份换取失败 + '401': + description: token 无效/已过期,且未提供 users_wx_code + '404': + description: 用户不存在 + '415': + description: 请求体必须为 application/json + '429': + description: 重复请求过于频繁 /api/wechat/login: post: tags: [微信认证] summary: 微信登录/注册合一 description: | - 使用微信 code 换取 openid。 + 使用微信 code 换取微信侧 openid,并写入统一身份字段 `tbl_auth_users.openid`。 若 `tbl_auth_users` 中不存在对应用户则自动创建 auth record,随后返回 PocketBase 原生 auth token。 + 首次注册创建时会写入 `users_idtype = WeChat`。 + 返回的 `token` 可直接用于 PocketBase SDK 与当前 hooks 接口调用。 requestBody: required: true content: @@ -201,7 +372,73 @@ paths: schema: $ref: '#/components/schemas/PocketBaseAuthResponse' '400': - description: 参数错误 + description: 参数错误或微信侧身份换取失败 + '401': + description: PocketBase 原生认证失败 + '415': + description: 请求体必须为 application/json + '429': + description: 重复请求过于频繁 + '500': + description: 保存 auth 用户失败或服务端内部错误 + /api/platform/register: + post: + tags: [平台认证] + summary: 平台用户注册 + description: | + 创建平台用户 auth record。 + 服务端会自动生成 GUID 并写入统一身份字段 `openid`,同时写入 `users_idtype = ManagePlatform`。 + 前端以 `users_phone + password/passwordConfirm` 注册,但服务端仍会创建 PocketBase 原生 auth 用户。 + 注册成功后直接返回 PocketBase 原生 auth token。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformRegisterRequest' + responses: + '200': + description: 注册成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PocketBaseAuthResponse' + '400': + description: 参数错误或手机号已存在 + '500': + description: GUID 生成失败、auth identity 缺失或保存用户失败 + '415': + description: 请求体必须为 application/json + '429': + description: 重复请求过于频繁 + /api/platform/login: + post: + tags: [平台认证] + summary: 平台用户登录 + description: | + 前端使用平台注册时保存的 `users_phone + password` 登录。 + 仅允许 `users_idtype = ManagePlatform` 的用户通过该接口登录。 + 服务端会先按手机号定位平台用户,再使用该用户的 PocketBase 原生 identity(当前为 `email`)执行原生 password auth。 + 登录成功后直接返回 PocketBase 原生 auth token。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformLoginRequest' + responses: + '200': + description: 登录成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PocketBaseAuthResponse' + '400': + description: 参数错误、密码错误或用户类型不匹配 + '404': + description: 平台用户不存在 + '500': + description: 平台用户缺少原生登录 identity 或服务端内部错误 '415': description: 请求体必须为 application/json '429': @@ -210,6 +447,9 @@ paths: post: tags: [微信认证] summary: 更新微信用户资料 + description: | + 基于当前 `Authorization` 对应 auth record 中的统一 `openid` 定位当前微信用户。 + 当前接口仍用于微信资料完善场景。 security: - bearerAuth: [] requestBody: @@ -231,23 +471,6 @@ paths: data: $ref: '#/components/schemas/WechatProfileResponseData' '401': - description: token 无效或当前 auth record 缺少 openid - /api/wechat/refresh-token: - post: - tags: [微信认证] - summary: 刷新 PocketBase 原生 token - description: | - 当前实现完全基于 PocketBase 原生鉴权,直接从当前 `Authorization` 对应的 auth record 读取 openid 并重新返回原生 auth token。 - security: - - bearerAuth: [] - responses: - '200': - description: 刷新成功 - content: - application/json: - schema: - $ref: '#/components/schemas/PocketBaseAuthResponse' - '401': - description: token 无效或当前 auth record 缺少 openid - '404': - description: 用户不存在 + description: token 无效或当前 auth record 缺少统一身份字段 openid + '400': + description: 参数错误、手机号已被注册或资料更新失败 diff --git a/script/pocketbase.newpb.js b/script/pocketbase.newpb.js index 069d9c5..5f00272 100644 --- a/script/pocketbase.newpb.js +++ b/script/pocketbase.newpb.js @@ -38,13 +38,12 @@ const collections = [ name: 'tbl_auth_users', type: 'auth', fields: [ - { name: 'user_id', type: 'text' }, + { name: 'users_convers_id', type: 'text' }, { name: 'openid', type: 'text', required: true }, - { name: 'user_name', type: 'text' }, { name: 'org_id', type: 'number' }, - { name: 'rank_level', type: 'number' }, - { name: 'status', type: 'number' }, - { name: 'user_auth_type', type: 'number' }, + { name: 'users_rank_level', type: 'number' }, + { name: 'users_status', type: 'number' }, + { name: 'users_auth_type', type: 'number' }, { name: 'users_id', type: 'text' }, { name: 'users_name', type: 'text' }, @@ -64,14 +63,13 @@ const collections = [ { name: 'usergroups_id', type: 'text' } ], indexes: [ - 'CREATE UNIQUE INDEX idx_tbl_auth_users_user_id ON tbl_auth_users (user_id)', + 'CREATE UNIQUE INDEX idx_tbl_auth_users_users_convers_id ON tbl_auth_users (users_convers_id)', 'CREATE UNIQUE INDEX idx_tbl_auth_users_openid ON tbl_auth_users (openid)', 'CREATE INDEX idx_tbl_auth_users_org_id ON tbl_auth_users (org_id)', - 'CREATE INDEX idx_tbl_auth_users_rank_level ON tbl_auth_users (rank_level)', - 'CREATE INDEX idx_tbl_auth_users_status ON tbl_auth_users (status)', - 'CREATE INDEX idx_tbl_auth_users_user_auth_type ON tbl_auth_users (user_auth_type)', + 'CREATE INDEX idx_tbl_auth_users_users_rank_level ON tbl_auth_users (users_rank_level)', + 'CREATE INDEX idx_tbl_auth_users_users_status ON tbl_auth_users (users_status)', + 'CREATE INDEX idx_tbl_auth_users_users_auth_type ON tbl_auth_users (users_auth_type)', - 'CREATE UNIQUE INDEX idx_tbl_auth_users_users_id ON tbl_auth_users (users_id)', // Allow unbound users with empty phone while still speeding up phone lookups. 'CREATE INDEX idx_tbl_auth_users_users_phone ON tbl_auth_users (users_phone)', 'CREATE INDEX idx_tbl_auth_users_company_id ON tbl_auth_users (company_id)', @@ -133,16 +131,16 @@ const collections = [ type: 'base', fields: [ { name: 'override_id', type: 'text', required: true }, - { name: 'user_id', type: 'text', required: true }, + { name: 'users_convers_id', type: 'text', required: true }, { name: 'res_id', type: 'text', required: true }, { name: 'access_level', type: 'number', required: true }, { name: 'priority', type: 'number' } ], indexes: [ 'CREATE UNIQUE INDEX idx_tbl_auth_user_overrides_override_id ON tbl_auth_user_overrides (override_id)', - 'CREATE INDEX idx_tbl_auth_user_overrides_user_id ON tbl_auth_user_overrides (user_id)', + 'CREATE INDEX idx_tbl_auth_user_overrides_users_convers_id ON tbl_auth_user_overrides (users_convers_id)', 'CREATE INDEX idx_tbl_auth_user_overrides_res_id ON tbl_auth_user_overrides (res_id)', - 'CREATE UNIQUE INDEX idx_tbl_auth_user_overrides_unique_map ON tbl_auth_user_overrides (user_id, res_id)' + 'CREATE UNIQUE INDEX idx_tbl_auth_user_overrides_unique_map ON tbl_auth_user_overrides (users_convers_id, res_id)' ] }, {