feat: 完善微信认证功能,新增用户资料更新与token刷新接口
- 新增 userService.js,包含用户认证、资料更新、token 刷新等功能 - 新增 wechatService.js,处理微信API交互,获取openid和手机号 - 新增 appError.js,封装应用错误处理 - 新增 logger.js,提供日志记录功能 - 新增 response.js,统一成功响应格式 - 新增 sanitize.js,提供输入数据清洗功能 - 更新 OpenAPI 文档,描述新增接口及请求响应格式 - 更新 PocketBase 数据库结构,调整用户表字段及索引策略 - 增强错误处理机制,确保错误信息可观测性 - 更新变更记录文档,详细记录本次变更内容
This commit is contained in:
139
pocket-base/README.md
Normal file
139
pocket-base/README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# PocketBase Hooks API Project
|
||||
|
||||
这是从 `back-end/` 迁移出来的 PocketBase `bai_api_pb_hooks` 原生 API 项目。
|
||||
|
||||
## 目标
|
||||
|
||||
- 使用 PocketBase 自定义路由承载对外 API
|
||||
- 最终部署时只复制 `bai_api_pb_hooks/` 整个目录到服务器的 PocketBase 实例目录
|
||||
- 按接口类型拆分不同子目录,便于维护
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
pocket-base/
|
||||
├─ bai-api-main.pb.js
|
||||
├─ bai_api_pb_hooks/
|
||||
│ ├─ bai_api_routes/
|
||||
│ │ ├─ system/
|
||||
│ │ └─ wechat/
|
||||
│ └─ bai_api_shared/
|
||||
│ ├─ config/
|
||||
│ ├─ middlewares/
|
||||
│ ├─ services/
|
||||
│ └─ utils/
|
||||
├─ spec/
|
||||
├─ tests/
|
||||
└─ scripts/
|
||||
```
|
||||
|
||||
## 当前接口
|
||||
|
||||
- `POST /api/system/test-helloworld`
|
||||
- `POST /api/system/health`
|
||||
- `POST /api/wechat/login`
|
||||
- `POST /api/wechat/profile`
|
||||
- `POST /api/wechat/refresh-token`
|
||||
|
||||
> 当前自定义路由统一使用 `/api/...` 前缀。
|
||||
|
||||
## 鉴权说明
|
||||
|
||||
- 当前接口统一使用 PocketBase 原生 `Authorization: Bearer <token>`。
|
||||
- `Open-Authorization` 不是本项目接口定义的 Header,如调试工具里出现,通常是工具全局预设,应删除。
|
||||
- `users_wx_openid` Header 已移除,不再需要客户端额外传递。
|
||||
- 当前用户身份以 PocketBase auth record 中的 `openid` 字段为准。
|
||||
|
||||
## 部署方式
|
||||
|
||||
由于服务器只有一个公共 `pb_hooks/` 目录,部署时请将以下内容复制到服务器 `pb_hooks/` 根目录下:
|
||||
|
||||
- `bai-api-main.pb.js`
|
||||
- `bai_api_pb_hooks/`
|
||||
|
||||
建议部署后的结构类似:
|
||||
|
||||
```text
|
||||
pb_hooks/
|
||||
├─ bai-api-main.pb.js
|
||||
└─ bai_api_pb_hooks/
|
||||
├─ bai_api_routes/
|
||||
└─ bai_api_shared/
|
||||
```
|
||||
|
||||
## 环境配置
|
||||
|
||||
PocketBase JSVM 不会自动读取 `back-end/.env`。当前 Hook 运行配置来自 **PocketBase 进程环境变量**。
|
||||
|
||||
已补充模板文件:
|
||||
|
||||
- `pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.example`
|
||||
- `pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.example.js`
|
||||
|
||||
至少需要在服务器的 PocketBase 运行环境中提供,或写入 `runtime.js`:
|
||||
|
||||
- `WECHAT_APPID`
|
||||
- `WECHAT_SECRET`
|
||||
|
||||
可选保留:
|
||||
|
||||
- `APP_BASE_URL`
|
||||
- `POCKETBASE_API_URL`
|
||||
- `POCKETBASE_AUTH_TOKEN`
|
||||
|
||||
说明:
|
||||
|
||||
- `WECHAT_APPID` / `WECHAT_SECRET` 是必须的,因为微信登录逻辑会直接调用微信官方接口。
|
||||
- 旧 `back-end/.env` 中的这些值可以作为来源参考,但 **不会被 pb_hooks 自动读取**。
|
||||
- 如果不方便改 PocketBase 进程环境,可在服务器创建:`pb_hooks/bai_api_pb_hooks/bai_api_shared/config/runtime.js`。
|
||||
- `runtime.js` 的内容可直接参考 `runtime.example.js`。
|
||||
- 当前 Hook 代码已不依赖自定义 JWT,因此 `JWT_SECRET`、`JWT_EXPIRES_IN` 不是运行必需项。
|
||||
|
||||
## 额外要求
|
||||
|
||||
部署前请确认 PocketBase 所在环境提供以下环境变量:
|
||||
|
||||
- `WECHAT_APPID`
|
||||
- `WECHAT_SECRET`
|
||||
如果你还需要构建时间展示,可额外提供:
|
||||
|
||||
- `BUILD_TIME`
|
||||
|
||||
## 注意
|
||||
|
||||
PocketBase JSVM 不是 Node.js 运行时:
|
||||
|
||||
- 不能直接复用 axios/jsonwebtoken/express 中间件
|
||||
- 只能使用 PocketBase 暴露的全局对象,如 `$app`、`$apis`、`$http`、`$security`
|
||||
- 共享逻辑必须通过 `require(`${__hooks}/...`)` 方式加载本地 CommonJS 模块
|
||||
|
||||
## 建议验证
|
||||
|
||||
迁移完成后,在 PocketBase 服务器上检查:
|
||||
|
||||
1. 自定义路由是否生效
|
||||
2. `tbl_auth_users`、`tbl_company` 集合名是否与当前数据库一致
|
||||
3. PocketBase 所在服务器是否能访问微信开放接口
|
||||
4. 反向代理是否放行 `/api/` 路由
|
||||
|
||||
## OpenSpec 变更记录
|
||||
|
||||
本项目 active hooks 相关的最新规范记录见:
|
||||
|
||||
- `pocket-base/spec/openapi.yaml`
|
||||
- `pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md`
|
||||
|
||||
本次变更重点包括:
|
||||
|
||||
- 微信登录链路错误显式返回
|
||||
- `recordAuthResponse` 使用空 `authMethod`
|
||||
- active hooks 中移除 `-created` 排序
|
||||
- `users_phone` 索引由唯一改为普通索引
|
||||
- `tbl_auth_users` 以 `openid` 为业务身份锚点
|
||||
- auth 集合兼容占位 `email`、随机密码与 `passwordConfirm`
|
||||
|
||||
## 与原项目关系
|
||||
|
||||
- 原 `back-end/` 保留不动
|
||||
- 当前 `pocket-base/` 为新的正式 Hook 项目
|
||||
- 后续只部署 `bai_api_pb_hooks/` 的内部内容到服务器 `pb_hooks/` 即可
|
||||
24
pocket-base/bai-api-main.pb.js
Normal file
24
pocket-base/bai-api-main.pb.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
routerUse(function (e) {
|
||||
try {
|
||||
return e.next()
|
||||
} catch (err) {
|
||||
const status =
|
||||
(err && typeof err.statusCode === 'number' && err.statusCode)
|
||||
|| (err && typeof err.status === 'number' && err.status)
|
||||
|| 500
|
||||
|
||||
return e.json(status, {
|
||||
code: status,
|
||||
msg: (err && err.message) || '服务器内部错误',
|
||||
data: (err && err.data) || {},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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/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`)
|
||||
@@ -0,0 +1,8 @@
|
||||
routerAdd('POST', '/api/system/health', function (e) {
|
||||
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
|
||||
|
||||
return success(e, '服务运行正常', {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
11
pocket-base/bai_api_pb_hooks/bai_api_routes/system/hello.js
Normal file
11
pocket-base/bai_api_pb_hooks/bai_api_routes/system/hello.js
Normal file
@@ -0,0 +1,11 @@
|
||||
routerAdd('POST', '/api/system/test-helloworld', 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, '请求成功', {
|
||||
message: 'Hello, World!',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
build_time: env.buildTime,
|
||||
})
|
||||
})
|
||||
33
pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/login.js
Normal file
33
pocket-base/bai_api_pb_hooks/bai_api_routes/wechat/login.js
Normal file
@@ -0,0 +1,33 @@
|
||||
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`)
|
||||
|
||||
try {
|
||||
guards.requireJson(e)
|
||||
guards.duplicateGuard(e)
|
||||
|
||||
const payload = guards.validateLoginBody(e)
|
||||
const data = userService.authenticateWechatUser(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) || {},
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
routerAdd('POST', '/api/wechat/profile', function (e) {
|
||||
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
|
||||
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`)
|
||||
|
||||
guards.requireJson(e)
|
||||
const authState = guards.requireWechatAuth(e)
|
||||
guards.duplicateGuard(e)
|
||||
|
||||
const payload = guards.validateProfileBody(e)
|
||||
const data = userService.updateWechatUserProfile(authState.usersWxOpenid, payload)
|
||||
|
||||
return success(e, '信息更新成功', data)
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
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
|
||||
})
|
||||
12
pocket-base/bai_api_pb_hooks/bai_api_shared/config/.env
Normal file
12
pocket-base/bai_api_pb_hooks/bai_api_shared/config/.env
Normal file
@@ -0,0 +1,12 @@
|
||||
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
|
||||
|
||||
#正式服
|
||||
#WECHAT_APPID=wx3bd7a7b19679da7a
|
||||
#WECHAT_SECRET=57e40438c2a9151257b1927674db10e1
|
||||
|
||||
#测试服
|
||||
WECHAT_APPID=wx42e9add0f91af98b
|
||||
WECHAT_SECRET=5620f00b40297efaf3d197d61ae184d6
|
||||
29
pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.js
Normal file
29
pocket-base/bai_api_pb_hooks/bai_api_shared/config/env.js
Normal file
@@ -0,0 +1,29 @@
|
||||
let runtimeConfig = {}
|
||||
|
||||
try {
|
||||
runtimeConfig = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/runtime.js`)
|
||||
} catch (_err) {
|
||||
runtimeConfig = {}
|
||||
}
|
||||
|
||||
function pick(key, fallback) {
|
||||
const envValue = $os.getenv(key)
|
||||
if (envValue !== '') return envValue
|
||||
|
||||
if (runtimeConfig && typeof runtimeConfig[key] !== 'undefined' && runtimeConfig[key] !== '') {
|
||||
return runtimeConfig[key]
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
nodeEnv: pick('NODE_ENV', 'production'),
|
||||
apiPrefix: '/api',
|
||||
wechatAppId: pick('WECHAT_APPID', ''),
|
||||
wechatSecret: pick('WECHAT_SECRET', ''),
|
||||
pocketbaseApiUrl: pick('POCKETBASE_API_URL', ''),
|
||||
pocketbaseAuthToken: pick('POCKETBASE_AUTH_TOKEN', ''),
|
||||
appBaseUrl: pick('APP_BASE_URL', ''),
|
||||
buildTime: pick('BUILD_TIME', null),
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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',
|
||||
/* WECHAT_APPID: 'wx3bd7a7b19679da7a',
|
||||
WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1', */
|
||||
WECHAT_APPID: 'wx42e9add0f91af98b',
|
||||
WECHAT_SECRET: '5620f00b40297efaf3d197d61ae184d6',
|
||||
BUILD_TIME: '',
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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',
|
||||
WECHAT_APPID: 'wx3bd7a7b19679da7a',
|
||||
WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1',
|
||||
/* WECHAT_APPID: 'wx42e9add0f91af98b',
|
||||
WECHAT_SECRET: '5620f00b40297efaf3d197d61ae184d6', */
|
||||
BUILD_TIME: '',
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
|
||||
const { sanitizePayload } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/sanitize.js`)
|
||||
|
||||
const requestCache = {}
|
||||
const WINDOW_MS = 5000
|
||||
|
||||
function parseBody(e) {
|
||||
return sanitizePayload(e.requestInfo().body || {})
|
||||
}
|
||||
|
||||
function requireJson(e) {
|
||||
const contentType = e.request.header.get('Content-Type') || e.request.header.get('content-type') || ''
|
||||
if (contentType.toLowerCase().indexOf('application/json') === -1) {
|
||||
throw createAppError(415, '请求体必须为 application/json')
|
||||
}
|
||||
}
|
||||
|
||||
function validateLoginBody(e) {
|
||||
const payload = parseBody(e)
|
||||
if (!payload.users_wx_code) {
|
||||
throw createAppError(400, 'users_wx_code 为必填项')
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function validateProfileBody(e) {
|
||||
const payload = parseBody(e)
|
||||
if (!payload.users_name) throw createAppError(400, 'users_name 为必填项')
|
||||
if (!payload.users_phone_code) throw createAppError(400, 'users_phone_code 为必填项')
|
||||
if (!payload.users_picture) throw createAppError(400, 'users_picture 为必填项')
|
||||
return payload
|
||||
}
|
||||
|
||||
function requireWechatOpenid(e) {
|
||||
if (!e.auth) {
|
||||
throw createAppError(401, '认证令牌无效或已过期')
|
||||
}
|
||||
|
||||
if (e.auth.collection().name !== 'tbl_auth_users') {
|
||||
throw createAppError(401, '认证用户集合不正确')
|
||||
}
|
||||
|
||||
const openid = e.auth.getString('openid')
|
||||
if (!openid) {
|
||||
throw createAppError(401, '当前认证用户缺少 openid')
|
||||
}
|
||||
|
||||
return openid
|
||||
}
|
||||
|
||||
function requireWechatAuth(e) {
|
||||
const authHeader = e.request.header.get('Authorization') || ''
|
||||
const parts = authHeader.split(' ')
|
||||
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer' || !parts[1]) {
|
||||
throw createAppError(401, '请求头缺少 Authorization')
|
||||
}
|
||||
|
||||
if (!e.auth) {
|
||||
throw createAppError(401, '认证令牌无效或已过期')
|
||||
}
|
||||
|
||||
if (e.auth.collection().name !== 'tbl_auth_users') {
|
||||
throw createAppError(401, '认证用户集合不正确')
|
||||
}
|
||||
|
||||
const authOpenid = e.auth.getString('openid')
|
||||
if (!authOpenid) {
|
||||
throw createAppError(401, '当前认证用户缺少 openid')
|
||||
}
|
||||
|
||||
return {
|
||||
usersWxOpenid: authOpenid,
|
||||
authRecord: e.auth,
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateGuard(e) {
|
||||
const key = String(e.realIP()) + ':' + String(e.request.url.pathname) + ':' + JSON.stringify(e.requestInfo().body || {})
|
||||
const now = Date.now()
|
||||
const lastTime = requestCache[key]
|
||||
|
||||
if (lastTime && now - lastTime < WINDOW_MS) {
|
||||
throw createAppError(429, '请求过于频繁,请稍后重试')
|
||||
}
|
||||
|
||||
requestCache[key] = now
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseBody,
|
||||
requireJson,
|
||||
validateLoginBody,
|
||||
validateProfileBody,
|
||||
requireWechatOpenid,
|
||||
requireWechatAuth,
|
||||
duplicateGuard,
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
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 GUEST_USER_TYPE = '游客'
|
||||
const REGISTERED_USER_TYPE = '注册用户'
|
||||
const mutationLocks = {}
|
||||
|
||||
function buildUserId() {
|
||||
const now = new Date()
|
||||
const yyyy = String(now.getFullYear())
|
||||
const mm = String(now.getMonth() + 1)
|
||||
const dd = String(now.getDate())
|
||||
const date = yyyy + (mm.length === 1 ? '0' + mm : mm) + (dd.length === 1 ? '0' + dd : dd)
|
||||
const suffix = String(Math.floor(Math.random() * 9000) + 1000)
|
||||
return 'U' + date + suffix
|
||||
}
|
||||
|
||||
function maskPhone(phone) {
|
||||
const value = phone || ''
|
||||
if (!value || value.length < 7) return ''
|
||||
return value.slice(0, 3) + '****' + value.slice(-4)
|
||||
}
|
||||
|
||||
function isInfoComplete(record) {
|
||||
return !!(record.getString('users_name') && record.getString('users_phone') && record.getString('users_picture'))
|
||||
}
|
||||
|
||||
function isAllProfileFieldsEmpty(record) {
|
||||
return !record.getString('users_name') && !record.getString('users_phone') && !record.getString('users_picture')
|
||||
}
|
||||
|
||||
function withUserLock(lockKey, handler) {
|
||||
if (mutationLocks[lockKey]) {
|
||||
throw createAppError(429, '请求过于频繁,请稍后重试')
|
||||
}
|
||||
|
||||
mutationLocks[lockKey] = true
|
||||
|
||||
try {
|
||||
return handler()
|
||||
} finally {
|
||||
delete mutationLocks[lockKey]
|
||||
}
|
||||
}
|
||||
|
||||
function findUserByOpenid(usersWxOpenid) {
|
||||
const records = $app.findRecordsByFilter('tbl_auth_users', 'openid = {:openid}', '', 1, 0, {
|
||||
openid: usersWxOpenid,
|
||||
})
|
||||
return records.length ? records[0] : null
|
||||
}
|
||||
|
||||
function getCompanyByCompanyId(companyId) {
|
||||
if (!companyId) return null
|
||||
const records = $app.findRecordsByFilter('tbl_company', 'company_id = {:companyId}', '', 1, 0, {
|
||||
companyId: companyId,
|
||||
})
|
||||
return records.length ? records[0] : null
|
||||
}
|
||||
|
||||
function exportCompany(companyRecord) {
|
||||
if (!companyRecord) return null
|
||||
return companyRecord.publicExport()
|
||||
}
|
||||
|
||||
function enrichUser(userRecord) {
|
||||
const companyId = userRecord.getString('company_id')
|
||||
const companyRecord = getCompanyByCompanyId(companyId)
|
||||
const openid = userRecord.getString('openid')
|
||||
|
||||
return {
|
||||
pb_id: userRecord.id,
|
||||
users_id: userRecord.getString('users_id') || userRecord.getString('user_id'),
|
||||
users_type: userRecord.getString('users_type') || GUEST_USER_TYPE,
|
||||
users_name: userRecord.getString('users_name') || userRecord.getString('user_name'),
|
||||
users_phone: userRecord.getString('users_phone'),
|
||||
users_phone_masked: maskPhone(userRecord.getString('users_phone')),
|
||||
users_picture: userRecord.getString('users_picture'),
|
||||
openid: openid,
|
||||
company_id: companyId || '',
|
||||
company: exportCompany(companyRecord),
|
||||
created: String(userRecord.created || ''),
|
||||
updated: String(userRecord.updated || ''),
|
||||
}
|
||||
}
|
||||
|
||||
function buildAuthMeta(user) {
|
||||
return {
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
data: {
|
||||
status: user.status,
|
||||
is_info_complete: user.is_info_complete,
|
||||
user: user.user,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAuthIdentity(record) {
|
||||
if (!record.getString('email')) {
|
||||
const openid = record.getString('openid')
|
||||
if (openid) {
|
||||
record.set('email', openid + '@wechat.local')
|
||||
}
|
||||
}
|
||||
|
||||
if (!record.getString('password')) {
|
||||
record.setPassword($security.randomString(24))
|
||||
}
|
||||
|
||||
if (!record.getString('passwordConfirm')) {
|
||||
record.set('passwordConfirm', record.getString('password'))
|
||||
}
|
||||
}
|
||||
|
||||
function saveAuthUserRecord(record) {
|
||||
try {
|
||||
$app.save(record)
|
||||
} catch (err) {
|
||||
throw createAppError(400, '保存微信用户失败', {
|
||||
originalMessage: (err && err.message) || '未知错误',
|
||||
originalData: (err && err.data) || {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function authenticateWechatUser(payload) {
|
||||
const openid = wechatService.getWxOpenId(payload.users_wx_code)
|
||||
|
||||
return withUserLock('auth:' + openid, function () {
|
||||
const existing = findUserByOpenid(openid)
|
||||
if (existing) {
|
||||
logger.warn('微信注册命中已存在账号', {
|
||||
openid: openid,
|
||||
users_type: existing.getString('users_type') || GUEST_USER_TYPE,
|
||||
})
|
||||
|
||||
const user = enrichUser(existing)
|
||||
|
||||
return {
|
||||
status: 'login_success',
|
||||
is_info_complete: isInfoComplete(existing),
|
||||
user: user,
|
||||
authRecord: existing,
|
||||
authMethod: '',
|
||||
meta: buildAuthMeta({
|
||||
status: 'login_success',
|
||||
is_info_complete: isInfoComplete(existing),
|
||||
user: user,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
record.set('users_type', GUEST_USER_TYPE)
|
||||
record.set('user_auth_type', 0)
|
||||
ensureAuthIdentity(record)
|
||||
saveAuthUserRecord(record)
|
||||
|
||||
const user = enrichUser(record)
|
||||
|
||||
logger.info('微信用户注册成功', {
|
||||
users_id: user.users_id,
|
||||
users_type: user.users_type,
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'register_success',
|
||||
is_info_complete: false,
|
||||
user: user,
|
||||
authRecord: record,
|
||||
authMethod: '',
|
||||
meta: buildAuthMeta({
|
||||
status: 'register_success',
|
||||
is_info_complete: false,
|
||||
user: user,
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateWechatUserProfile(usersWxOpenid, payload) {
|
||||
return withUserLock('profile:' + usersWxOpenid, function () {
|
||||
const currentUser = findUserByOpenid(usersWxOpenid)
|
||||
if (!currentUser) {
|
||||
throw createAppError(404, '未找到待编辑的用户')
|
||||
}
|
||||
|
||||
const usersPhone = wechatService.getWxPhoneNumber(payload.users_phone_code)
|
||||
|
||||
if (usersPhone && usersPhone !== currentUser.getString('users_phone')) {
|
||||
const samePhoneUsers = $app.findRecordsByFilter('tbl_auth_users', 'users_phone = {:phone}', '', 10, 0, {
|
||||
phone: usersPhone,
|
||||
})
|
||||
for (let i = 0; i < samePhoneUsers.length; i += 1) {
|
||||
if (samePhoneUsers[i].id !== currentUser.id) {
|
||||
throw createAppError(400, '手机号已被注册')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shouldPromote = isAllProfileFieldsEmpty(currentUser)
|
||||
&& !!payload.users_name
|
||||
&& !!usersPhone
|
||||
&& !!payload.users_picture
|
||||
&& ((currentUser.getString('users_type') || GUEST_USER_TYPE) === GUEST_USER_TYPE)
|
||||
|
||||
currentUser.set('users_name', payload.users_name)
|
||||
currentUser.set('user_name', payload.users_name)
|
||||
currentUser.set('users_phone', usersPhone)
|
||||
currentUser.set('users_picture', payload.users_picture)
|
||||
if (shouldPromote) {
|
||||
currentUser.set('users_type', REGISTERED_USER_TYPE)
|
||||
}
|
||||
|
||||
saveAuthUserRecord(currentUser)
|
||||
|
||||
const user = enrichUser(currentUser)
|
||||
|
||||
logger.info('微信用户资料更新成功', {
|
||||
users_id: user.users_id,
|
||||
users_type_after: user.users_type,
|
||||
users_type_promoted: shouldPromote,
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'update_success',
|
||||
user: user,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function refreshWechatToken(usersWxOpenid) {
|
||||
const userRecord = findUserByOpenid(usersWxOpenid)
|
||||
if (!userRecord) {
|
||||
throw createAppError(404, '未注册用户')
|
||||
}
|
||||
|
||||
logger.info('微信用户刷新令牌成功', {
|
||||
users_id: userRecord.getString('users_id') || userRecord.getString('user_id'),
|
||||
openid: userRecord.getString('openid'),
|
||||
})
|
||||
|
||||
return {
|
||||
authRecord: userRecord,
|
||||
authMethod: '',
|
||||
meta: {
|
||||
code: 200,
|
||||
msg: '刷新成功',
|
||||
data: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticateWechatUser,
|
||||
updateWechatUserProfile,
|
||||
refreshWechatToken,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`)
|
||||
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
|
||||
|
||||
let accessTokenCache = {
|
||||
token: '',
|
||||
expiresAt: 0,
|
||||
}
|
||||
|
||||
function ensureWechatConfig() {
|
||||
if (!env.wechatAppId || !env.wechatSecret) {
|
||||
throw createAppError(500, '微信小程序配置缺失')
|
||||
}
|
||||
}
|
||||
|
||||
function getWxOpenId(code) {
|
||||
ensureWechatConfig()
|
||||
|
||||
const res = $http.send({
|
||||
url: 'https://api.weixin.qq.com/sns/jscode2session?appid=' + encodeURIComponent(env.wechatAppId) + '&secret=' + encodeURIComponent(env.wechatSecret) + '&js_code=' + encodeURIComponent(code) + '&grant_type=authorization_code',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
timeout: 10,
|
||||
})
|
||||
|
||||
const data = res.json || {}
|
||||
|
||||
if (data.openid) return data.openid
|
||||
|
||||
if (data.errcode || data.errmsg) {
|
||||
throw createAppError(502, ('获取OpenID失败: ' + (data.errcode || '') + ' ' + (data.errmsg || '')).replace(/^\s+|\s+$/g, ''))
|
||||
}
|
||||
|
||||
throw createAppError(502, '获取OpenID失败: 响应中未包含openid')
|
||||
}
|
||||
|
||||
function getWechatAccessToken() {
|
||||
if (accessTokenCache.token && Date.now() < accessTokenCache.expiresAt) {
|
||||
return accessTokenCache.token
|
||||
}
|
||||
|
||||
ensureWechatConfig()
|
||||
|
||||
const res = $http.send({
|
||||
url: 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=' + encodeURIComponent(env.wechatAppId) + '&secret=' + encodeURIComponent(env.wechatSecret),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
timeout: 10,
|
||||
})
|
||||
|
||||
const data = res.json || {}
|
||||
if (!data.access_token) {
|
||||
throw createAppError(502, ('获取微信 access_token 失败: ' + (data.errcode || '') + ' ' + (data.errmsg || '')).replace(/^\s+|\s+$/g, ''))
|
||||
}
|
||||
|
||||
accessTokenCache = {
|
||||
token: data.access_token,
|
||||
expiresAt: Date.now() + Math.max(((data.expires_in || 7200) - 300), 60) * 1000,
|
||||
}
|
||||
|
||||
return accessTokenCache.token
|
||||
}
|
||||
|
||||
function getWxPhoneNumber(phoneCode) {
|
||||
const accessToken = getWechatAccessToken()
|
||||
|
||||
const res = $http.send({
|
||||
url: 'https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=' + encodeURIComponent(accessToken),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code: phoneCode }),
|
||||
timeout: 10,
|
||||
})
|
||||
|
||||
const data = res.json || {}
|
||||
const phoneInfo = data.phone_info || {}
|
||||
const phone = phoneInfo.purePhoneNumber || phoneInfo.phoneNumber
|
||||
|
||||
if (phone) return phone
|
||||
|
||||
throw createAppError(502, ('获取微信手机号失败: ' + (data.errcode || '') + ' ' + (data.errmsg || '')).replace(/^\s+|\s+$/g, ''))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getWxOpenId,
|
||||
getWxPhoneNumber,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
function createAppError(statusCode, message, data) {
|
||||
return new ApiError(statusCode || 500, message || '服务器内部错误', data || {})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAppError,
|
||||
}
|
||||
14
pocket-base/bai_api_pb_hooks/bai_api_shared/utils/logger.js
Normal file
14
pocket-base/bai_api_pb_hooks/bai_api_shared/utils/logger.js
Normal file
@@ -0,0 +1,14 @@
|
||||
function log(level, message, meta) {
|
||||
console.log(JSON.stringify({
|
||||
time: new Date().toISOString(),
|
||||
level: level,
|
||||
message: message,
|
||||
meta: meta || {},
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
info: function (message, meta) { log('INFO', message, meta) },
|
||||
warn: function (message, meta) { log('WARN', message, meta) },
|
||||
error: function (message, meta) { log('ERROR', message, meta) },
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
function success(e, msg, data, code) {
|
||||
return e.json(code || 200, {
|
||||
code: code || 200,
|
||||
msg: msg || '操作成功',
|
||||
data: data || {},
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
success,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
function sanitizeString(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
return value.replace(/[<>\\]/g, '').replace(/^\s+|\s+$/g, '')
|
||||
}
|
||||
|
||||
function sanitizePayload(payload) {
|
||||
const source = payload || {}
|
||||
const result = {}
|
||||
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) continue
|
||||
result[key] = typeof source[key] === 'string' ? sanitizeString(source[key]) : source[key]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeString,
|
||||
sanitizePayload,
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
# OpenSpec 变更记录:PocketBase Hooks 认证链路加固
|
||||
|
||||
## 日期
|
||||
|
||||
- 2026-03-23
|
||||
|
||||
## 范围
|
||||
|
||||
本次变更覆盖 `pocket-base/` 下 PocketBase hooks 项目的微信登录、资料更新、token 刷新、认证落库、错误可观测性、索引策略与运行规范。
|
||||
|
||||
---
|
||||
|
||||
## 一、认证模型调整
|
||||
|
||||
### 1. 认证体系
|
||||
|
||||
- 保持 PocketBase 原生 auth token 作为唯一正式认证令牌。
|
||||
- 登录与刷新响应统一通过 `$apis.recordAuthResponse(...)` 返回 PocketBase 原生认证结果。
|
||||
- `authMethod` 统一使用空字符串 `''`,避免触发不必要的 MFA / login alerts 校验。
|
||||
|
||||
### 2. Header 规则
|
||||
|
||||
- 正式认证 Header 为:`Authorization: Bearer <token>`
|
||||
- 非标准 Header `Open-Authorization` 不属于本项目接口定义。
|
||||
- `users_wx_openid` Header 已从 active hooks 鉴权链路移除。
|
||||
|
||||
---
|
||||
|
||||
## 二、身份字段与数据模型约束
|
||||
|
||||
### 1. openid 作为唯一业务身份锚点
|
||||
|
||||
- `tbl_auth_users` 仅保留 `openid` 作为微信身份锚点。
|
||||
- 业务逻辑中不再使用 `users_wx_openid`。
|
||||
- 用户查询、token 刷新、资料更新均基于 auth record 的 `openid`。
|
||||
|
||||
### 2. auth 集合兼容字段
|
||||
|
||||
由于 `tbl_auth_users` 当前为 PocketBase `auth` 集合,登录注册时为兼容 PocketBase 原生 auth 校验,新增以下兼容策略:
|
||||
|
||||
- `email` 使用占位格式:`<openid>@wechat.local`
|
||||
- 自动生成随机密码
|
||||
- 自动补齐 `passwordConfirm`
|
||||
|
||||
说明:
|
||||
|
||||
- 占位 `email` 仅用于满足 auth 集合保存条件,不代表用户真实邮箱。
|
||||
- 业务主身份仍然是 `openid`。
|
||||
|
||||
### 3. 自定义字段可空策略
|
||||
|
||||
- `tbl_auth_users` 的自定义字段目标约束为:除 `openid` 外,其余业务字段均允许为空。
|
||||
- 已将 schema 脚本中的 `user_id` 改为非必填。
|
||||
- 其余业务字段保持非必填。
|
||||
|
||||
---
|
||||
|
||||
## 三、查询与排序修复
|
||||
|
||||
### 1. 移除无意义的 `created` 排序
|
||||
|
||||
在 hooks 查询中,以下查询原先使用 `'-created'` 排序:
|
||||
|
||||
- 按 `openid` 查询用户
|
||||
- 按 `company_id` 查询公司
|
||||
- 按 `users_phone` 查询重复手机号
|
||||
|
||||
该写法在 PocketBase 当前运行场景下触发:
|
||||
|
||||
- `invalid sort field "created"`
|
||||
|
||||
现已统一移除排序参数,改为空排序字符串,因为这些查询本质上均为精确匹配或去重检查,不依赖排序。
|
||||
|
||||
---
|
||||
|
||||
## 四、错误可观测性增强
|
||||
|
||||
### 1. 登录路由显式错误响应
|
||||
|
||||
`POST /api/wechat/login` 新增局部 try/catch:
|
||||
|
||||
- 保留业务状态码
|
||||
- 返回 `{ code, msg, data }`
|
||||
- 写入 `logger.error('微信登录失败', ...)`
|
||||
|
||||
### 2. 全局错误包装顺序修正
|
||||
|
||||
- `routerUse(...)` 全局错误包装提前到路由注册前。
|
||||
- 统一兼容 `err.statusCode` / `err.status`。
|
||||
|
||||
### 3. auth 保存失败透传
|
||||
|
||||
新增 `saveAuthUserRecord(record)` 包装 `$app.save(record)`:
|
||||
|
||||
- 失败时统一抛出 `保存微信用户失败`
|
||||
- 附带 `originalMessage` 与 `originalData`
|
||||
|
||||
目的:
|
||||
|
||||
- 避免 PocketBase 默认 `Something went wrong while processing your request.` 吞掉具体原因。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库索引策略修复
|
||||
|
||||
### 1. users_phone 索引调整
|
||||
|
||||
原设计:
|
||||
|
||||
- `users_phone` 唯一索引
|
||||
|
||||
问题:
|
||||
|
||||
- 新用户注册阶段手机号为空,多个空值会触发唯一约束冲突,导致注册失败。
|
||||
|
||||
现调整为:
|
||||
|
||||
- `users_phone` 普通索引
|
||||
|
||||
说明:
|
||||
|
||||
- 手机号唯一性改由业务逻辑在资料完善阶段校验。
|
||||
- 允许多个未完善资料用户以空手机号存在。
|
||||
|
||||
---
|
||||
|
||||
## 六、接口契约同步结果
|
||||
|
||||
当前 active PocketBase hooks 契约如下:
|
||||
|
||||
- `POST /api/system/test-helloworld`
|
||||
- `POST /api/system/health`
|
||||
- `POST /api/wechat/login`
|
||||
- `POST /api/wechat/profile`
|
||||
- `POST /api/wechat/refresh-token`
|
||||
|
||||
其中:
|
||||
|
||||
### `POST /api/wechat/login`
|
||||
|
||||
- body 必填:`users_wx_code`
|
||||
- 自动以微信 code 换取 `openid`
|
||||
- 若不存在 auth 用户则尝试创建 `tbl_auth_users` 记录
|
||||
- 成功时返回 PocketBase 原生 token + auth record + meta
|
||||
|
||||
### `POST /api/wechat/profile`
|
||||
|
||||
- 需 `Authorization`
|
||||
- 基于当前 auth record 的 `openid` 定位用户
|
||||
- 服务端用 `users_phone_code` 换取手机号后保存
|
||||
|
||||
### `POST /api/wechat/refresh-token`
|
||||
|
||||
- 需 `Authorization`
|
||||
- 直接基于当前 auth record 返回新的 PocketBase 原生 token
|
||||
|
||||
---
|
||||
|
||||
## 七、当前已知边界
|
||||
|
||||
1. `tbl_auth_users` 为 PocketBase `auth` 集合,因此仍受 PocketBase auth 内置规则影响。
|
||||
2. 自定义字段“除 openid 外均可空”已在脚本层按目标放宽,但 auth 集合结构更新仍可能触发 PocketBase 服务端限制。
|
||||
3. 若线上仍返回 PocketBase 默认 400,需要确保最新 hooks 已部署并重启生效。
|
||||
|
||||
---
|
||||
|
||||
## 八、归档建议
|
||||
|
||||
部署时至少同步以下文件:
|
||||
|
||||
- `pocket-base/bai-api-main.pb.js`
|
||||
- `pocket-base/bai_api_pb_hooks/`
|
||||
- `script/pocketbase.newpb.js`
|
||||
|
||||
并在 PocketBase 环境中执行 schema 同步后重启服务,再进行接口验证。
|
||||
253
pocket-base/spec/openapi.yaml
Normal file
253
pocket-base/spec/openapi.yaml
Normal file
@@ -0,0 +1,253 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: BAI PocketBase Hooks API
|
||||
description: 基于 PocketBase `bai_api_pb_hooks` 的对外接口文档,可直接导入 Postman。
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: https://bai-api.blv-oa.com/pb
|
||||
description: 生产环境
|
||||
- url: http://localhost:8090
|
||||
description: PocketBase 本地环境
|
||||
tags:
|
||||
- name: 系统
|
||||
description: 基础检查接口
|
||||
- name: 微信认证
|
||||
description: 基于微信 openid 与 PocketBase 原生 token 的认证接口
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: PocketBaseAuthToken
|
||||
schemas:
|
||||
ApiResponse:
|
||||
type: object
|
||||
required: [code, msg, data]
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
example: 200
|
||||
msg:
|
||||
type: string
|
||||
example: 操作成功
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
HealthData:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: healthy
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
HelloWorldData:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
example: Hello, World!
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
status:
|
||||
type: string
|
||||
example: success
|
||||
build_time:
|
||||
type: string
|
||||
nullable: true
|
||||
format: date-time
|
||||
CompanyInfo:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
UserInfo:
|
||||
type: object
|
||||
properties:
|
||||
pb_id:
|
||||
type: string
|
||||
users_id:
|
||||
type: string
|
||||
users_type:
|
||||
type: string
|
||||
enum: [游客, 注册用户]
|
||||
users_name:
|
||||
type: string
|
||||
users_phone:
|
||||
type: string
|
||||
users_phone_masked:
|
||||
type: string
|
||||
users_picture:
|
||||
type: string
|
||||
openid:
|
||||
type: string
|
||||
company_id:
|
||||
type: string
|
||||
company:
|
||||
$ref: '#/components/schemas/CompanyInfo'
|
||||
created:
|
||||
type: string
|
||||
updated:
|
||||
type: string
|
||||
PocketBaseAuthResponse:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: PocketBase 原生 auth token
|
||||
record:
|
||||
type: object
|
||||
description: PocketBase auth record 原始对象
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
example: 200
|
||||
msg:
|
||||
type: string
|
||||
example: 登录成功
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [register_success, login_success]
|
||||
is_info_complete:
|
||||
type: boolean
|
||||
user:
|
||||
$ref: '#/components/schemas/UserInfo'
|
||||
WechatLoginRequest:
|
||||
type: object
|
||||
required: [users_wx_code]
|
||||
properties:
|
||||
users_wx_code:
|
||||
type: string
|
||||
description: 微信小程序登录临时凭证 code
|
||||
example: 0a1b2c3d4e5f6g
|
||||
WechatProfileRequest:
|
||||
type: object
|
||||
required: [users_name, users_phone_code, users_picture]
|
||||
properties:
|
||||
users_name:
|
||||
type: string
|
||||
example: 张三
|
||||
users_phone_code:
|
||||
type: string
|
||||
example: 2b7d9f2e3c4a5b6d7e8f
|
||||
users_picture:
|
||||
type: string
|
||||
example: https://example.com/avatar.png
|
||||
WechatProfileResponseData:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [update_success]
|
||||
user:
|
||||
$ref: '#/components/schemas/UserInfo'
|
||||
paths:
|
||||
/api/system/test-helloworld:
|
||||
post:
|
||||
tags: [系统]
|
||||
summary: HelloWorld 测试接口
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/HelloWorldData'
|
||||
/api/system/health:
|
||||
post:
|
||||
tags: [系统]
|
||||
summary: 健康检查
|
||||
responses:
|
||||
'200':
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/HealthData'
|
||||
/api/wechat/login:
|
||||
post:
|
||||
tags: [微信认证]
|
||||
summary: 微信登录/注册合一
|
||||
description: |
|
||||
使用微信 code 换取 openid。
|
||||
若 `tbl_auth_users` 中不存在对应用户则自动创建 auth record,随后返回 PocketBase 原生 auth token。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WechatLoginRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 登录或注册成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseAuthResponse'
|
||||
'400':
|
||||
description: 参数错误
|
||||
'415':
|
||||
description: 请求体必须为 application/json
|
||||
'429':
|
||||
description: 重复请求过于频繁
|
||||
/api/wechat/profile:
|
||||
post:
|
||||
tags: [微信认证]
|
||||
summary: 更新微信用户资料
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WechatProfileRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 更新成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
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: 用户不存在
|
||||
Reference in New Issue
Block a user