feat: 完善微信认证功能,新增用户资料更新与token刷新接口

- 新增 userService.js,包含用户认证、资料更新、token 刷新等功能
- 新增 wechatService.js,处理微信API交互,获取openid和手机号
- 新增 appError.js,封装应用错误处理
- 新增 logger.js,提供日志记录功能
- 新增 response.js,统一成功响应格式
- 新增 sanitize.js,提供输入数据清洗功能
- 更新 OpenAPI 文档,描述新增接口及请求响应格式
- 更新 PocketBase 数据库结构,调整用户表字段及索引策略
- 增强错误处理机制,确保错误信息可观测性
- 更新变更记录文档,详细记录本次变更内容
This commit is contained in:
2026-03-24 10:36:19 +08:00
parent d5dc47fc07
commit 02d5686c7b
76 changed files with 1722 additions and 2641 deletions

139
pocket-base/README.md Normal file
View 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/` 即可

View 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`)

View File

@@ -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(),
})
})

View 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,
})
})

View 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) || {},
})
}
})

View File

@@ -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)
})

View File

@@ -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
})

View 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

View 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),
}

View File

@@ -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: '',
}

View File

@@ -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: '',
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -0,0 +1,7 @@
function createAppError(statusCode, message, data) {
return new ApiError(statusCode || 500, message || '服务器内部错误', data || {})
}
module.exports = {
createAppError,
}

View 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) },
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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 同步后重启服务,再进行接口验证。

View 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: 用户不存在