feat: 完善微信认证功能,新增用户资料更新与token刷新接口
- 新增 userService.js,包含用户认证、资料更新、token 刷新等功能 - 新增 wechatService.js,处理微信API交互,获取openid和手机号 - 新增 appError.js,封装应用错误处理 - 新增 logger.js,提供日志记录功能 - 新增 response.js,统一成功响应格式 - 新增 sanitize.js,提供输入数据清洗功能 - 更新 OpenAPI 文档,描述新增接口及请求响应格式 - 更新 PocketBase 数据库结构,调整用户表字段及索引策略 - 增强错误处理机制,确保错误信息可观测性 - 更新变更记录文档,详细记录本次变更内容
This commit is contained in:
@@ -12,12 +12,4 @@ APP_BASE_URL=https://bai-api.blv-oa.com
|
|||||||
|
|
||||||
# Database Configuration (Pocketbase)
|
# Database Configuration (Pocketbase)
|
||||||
POCKETBASE_API_URL=https://bai-api.blv-oa.com/pb/
|
POCKETBASE_API_URL=https://bai-api.blv-oa.com/pb/
|
||||||
POCKETBASE_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo
|
POCKETBASE_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDMxNTc3OSwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.PJqbHcHQ57WLYKQdycA-a96EwI5IFuKM1Mr1o-CNw_g
|
||||||
|
|
||||||
# WeChat Configuration
|
|
||||||
WECHAT_APPID=wx3bd7a7b19679da7a
|
|
||||||
WECHAT_SECRET=57e40438c2a9151257b1927674db10e1
|
|
||||||
|
|
||||||
# JWT Configuration
|
|
||||||
JWT_SECRET=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
|
||||||
JWT_EXPIRES_IN=2h
|
|
||||||
|
|||||||
23
back-end/dist/.env
vendored
23
back-end/dist/.env
vendored
@@ -1,23 +0,0 @@
|
|||||||
# Server Configuration
|
|
||||||
PORT=3002
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
API_PREFIX=/api
|
|
||||||
APP_PROTOCOL=https
|
|
||||||
APP_DOMAIN=bai-api.blv-oa.com
|
|
||||||
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.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo
|
|
||||||
|
|
||||||
# WeChat Configuration
|
|
||||||
WECHAT_APPID=wx3bd7a7b19679da7a
|
|
||||||
WECHAT_SECRET=57e40438c2a9151257b1927674db10e1
|
|
||||||
|
|
||||||
# JWT Configuration
|
|
||||||
JWT_SECRET=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
|
||||||
JWT_EXPIRES_IN=2h
|
|
||||||
3
back-end/dist/build-info.json
vendored
3
back-end/dist/build-info.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"buildTime": "2026-03-20T14:36:26.964Z"
|
|
||||||
}
|
|
||||||
13
back-end/dist/eslint.config.js
vendored
13
back-end/dist/eslint.config.js
vendored
@@ -1,13 +0,0 @@
|
|||||||
module.exports = [
|
|
||||||
{
|
|
||||||
files: ['src/**/*.js', 'tests/**/*.js'],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2022,
|
|
||||||
sourceType: 'commonjs',
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
semi: ['error', 'never'],
|
|
||||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
32
back-end/dist/package.json
vendored
32
back-end/dist/package.json
vendored
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "web-bai-manage-api-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Backend API server for BAI Management System",
|
|
||||||
"main": "dist/src/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "node src/index.js",
|
|
||||||
"build": "node scripts/build.js",
|
|
||||||
"start": "node dist/src/index.js",
|
|
||||||
"prestart": "node -e \"require('fs').accessSync('dist/src/index.js')\"",
|
|
||||||
"test": "node --test tests/**/*.test.js",
|
|
||||||
"lint": "eslint src tests --ext .js",
|
|
||||||
"spec:lint": "npx @fission-ai/openspec lint spec/",
|
|
||||||
"spec:validate": "npx @fission-ai/openspec validate spec/"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"axios": "^1.13.2",
|
|
||||||
"express": "^4.18.2",
|
|
||||||
"dotenv": "^16.3.1",
|
|
||||||
"jsonwebtoken": "^9.0.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@fission-ai/openspec": "^1.0.0",
|
|
||||||
"eslint": "^9.23.0",
|
|
||||||
"supertest": "^7.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.0.0"
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC"
|
|
||||||
}
|
|
||||||
382
back-end/dist/spec/openapi.yaml
vendored
382
back-end/dist/spec/openapi.yaml
vendored
@@ -1,382 +0,0 @@
|
|||||||
openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: BAI Management API
|
|
||||||
description: BAI 管理系统后端 API 文档
|
|
||||||
version: 1.0.0
|
|
||||||
servers:
|
|
||||||
- url: https://bai-api.blv-oa.com
|
|
||||||
description: BAI-api生产环境
|
|
||||||
- url: http://localhost:3000
|
|
||||||
description: BAI-api本地开发环境
|
|
||||||
tags:
|
|
||||||
- name: 系统
|
|
||||||
description: 基础健康检查接口
|
|
||||||
- name: 微信小程序用户
|
|
||||||
description: 微信小程序注册、登录与鉴权接口
|
|
||||||
components:
|
|
||||||
securitySchemes:
|
|
||||||
bearerAuth:
|
|
||||||
type: http
|
|
||||||
scheme: bearer
|
|
||||||
bearerFormat: JWT
|
|
||||||
schemas:
|
|
||||||
ApiResponse:
|
|
||||||
type: object
|
|
||||||
required: [code, msg, data]
|
|
||||||
properties:
|
|
||||||
code:
|
|
||||||
type: integer
|
|
||||||
description: 业务状态码
|
|
||||||
example: 200
|
|
||||||
msg:
|
|
||||||
type: string
|
|
||||||
description: 响应消息
|
|
||||||
example: 操作成功
|
|
||||||
data:
|
|
||||||
type: object
|
|
||||||
description: 响应数据
|
|
||||||
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
|
|
||||||
description: 最近一次构建时间;开发模式下若未执行构建则可能为空
|
|
||||||
format: date-time
|
|
||||||
WechatProfileRequest:
|
|
||||||
type: object
|
|
||||||
required: [users_name, users_phone_code, users_picture]
|
|
||||||
properties:
|
|
||||||
users_name:
|
|
||||||
type: string
|
|
||||||
description: 用户姓名
|
|
||||||
example: 张三
|
|
||||||
users_phone_code:
|
|
||||||
type: string
|
|
||||||
description: 微信手机号获取凭证 code,由后端换取真实手机号后写入 users_phone
|
|
||||||
example: 2b7d9f2e3c4a5b6d7e8f
|
|
||||||
users_picture:
|
|
||||||
type: string
|
|
||||||
description: 用户头像 URL
|
|
||||||
example: https://example.com/avatar.png
|
|
||||||
WechatAuthRequest:
|
|
||||||
type: object
|
|
||||||
required: [users_wx_code]
|
|
||||||
properties:
|
|
||||||
users_wx_code:
|
|
||||||
type: string
|
|
||||||
description: 微信小程序登录临时凭证 code
|
|
||||||
example: 0a1b2c3d4e5f6g
|
|
||||||
CompanyInfo:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
company_id:
|
|
||||||
type: string
|
|
||||||
example: C10001
|
|
||||||
company_name:
|
|
||||||
type: string
|
|
||||||
example: 示例科技有限公司
|
|
||||||
company_type:
|
|
||||||
type: string
|
|
||||||
example: 科技服务
|
|
||||||
company_entity:
|
|
||||||
type: string
|
|
||||||
example: 李四
|
|
||||||
company_usci:
|
|
||||||
type: string
|
|
||||||
example: 91330100XXXXXXXXXX
|
|
||||||
company_nationality:
|
|
||||||
type: string
|
|
||||||
example: 中国
|
|
||||||
company_province:
|
|
||||||
type: string
|
|
||||||
example: 浙江省
|
|
||||||
company_city:
|
|
||||||
type: string
|
|
||||||
example: 杭州市
|
|
||||||
company_postalcode:
|
|
||||||
type: string
|
|
||||||
example: "310000"
|
|
||||||
company_add:
|
|
||||||
type: string
|
|
||||||
example: 某某大道 100 号
|
|
||||||
company_status:
|
|
||||||
type: string
|
|
||||||
example: 正常
|
|
||||||
company_level:
|
|
||||||
type: string
|
|
||||||
example: A
|
|
||||||
company_remark:
|
|
||||||
type: string
|
|
||||||
example: 重点合作客户
|
|
||||||
UserInfo:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
users_id:
|
|
||||||
type: string
|
|
||||||
example: U202603190001
|
|
||||||
users_type:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
用户类型。
|
|
||||||
- `游客`:仅完成微信新号注册,尚未首次完整补充 `users_name`、`users_phone`、`users_picture`
|
|
||||||
- `注册用户`:用户曾从“三项资料均为空”首次补充为“三项资料均完整”
|
|
||||||
enum: [游客, 注册用户]
|
|
||||||
example: 游客
|
|
||||||
users_name:
|
|
||||||
type: string
|
|
||||||
example: 张三
|
|
||||||
users_phone:
|
|
||||||
type: string
|
|
||||||
example: "13800138000"
|
|
||||||
users_phone_masked:
|
|
||||||
type: string
|
|
||||||
example: "138****8000"
|
|
||||||
users_picture:
|
|
||||||
type: string
|
|
||||||
example: https://example.com/avatar.png
|
|
||||||
users_wx_openid:
|
|
||||||
type: string
|
|
||||||
example: oAbCdEfGh123456789
|
|
||||||
company_id:
|
|
||||||
type: string
|
|
||||||
example: C10001
|
|
||||||
company:
|
|
||||||
$ref: '#/components/schemas/CompanyInfo'
|
|
||||||
pb_id:
|
|
||||||
type: string
|
|
||||||
description: PocketBase 记录 id
|
|
||||||
example: abc123xyz
|
|
||||||
created:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
updated:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
WechatProfileResponseData:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
description: 信息编辑状态
|
|
||||||
enum: [update_success, update_failed]
|
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserInfo'
|
|
||||||
WechatAuthResponseData:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
description: 登录/注册结果状态
|
|
||||||
enum: [register_success, login_success]
|
|
||||||
is_info_complete:
|
|
||||||
type: boolean
|
|
||||||
description: 用户资料是否已完善
|
|
||||||
example: true
|
|
||||||
token:
|
|
||||||
type: string
|
|
||||||
description: JWT 令牌
|
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserInfo'
|
|
||||||
paths:
|
|
||||||
/api/test-helloworld:
|
|
||||||
post:
|
|
||||||
tags: [系统]
|
|
||||||
summary: Test endpoint
|
|
||||||
description: Returns a hello world message and build time
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Successful response
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/ApiResponse'
|
|
||||||
- type: object
|
|
||||||
properties:
|
|
||||||
data:
|
|
||||||
$ref: '#/components/schemas/HelloWorldData'
|
|
||||||
/api/health:
|
|
||||||
post:
|
|
||||||
tags: [系统]
|
|
||||||
summary: 健康检查
|
|
||||||
description: 检查服务是否正常运行
|
|
||||||
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: |
|
|
||||||
使用微信小程序临时登录凭证换取 openid。
|
|
||||||
若用户不存在,则自动创建新账号并返回完整用户信息;若已存在,则直接登录并返回完整用户信息。
|
|
||||||
登录/注册合一接口不处理手机号获取。
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- users_wx_code
|
|
||||||
properties:
|
|
||||||
users_wx_code:
|
|
||||||
type: string
|
|
||||||
description: 微信小程序登录临时凭证 code
|
|
||||||
example: 0a1b2c3d4e5f6g
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 登录或注册成功
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/ApiResponse'
|
|
||||||
- type: object
|
|
||||||
properties:
|
|
||||||
data:
|
|
||||||
$ref: '#/components/schemas/WechatAuthResponseData'
|
|
||||||
'400':
|
|
||||||
description: 参数错误
|
|
||||||
'415':
|
|
||||||
description: 请求体格式错误,仅支持 application/json
|
|
||||||
'429':
|
|
||||||
description: 重复提交过于频繁
|
|
||||||
'500':
|
|
||||||
description: 服务端异常
|
|
||||||
/api/wechat/profile:
|
|
||||||
post:
|
|
||||||
tags: [微信小程序用户]
|
|
||||||
summary: 微信小程序用户信息编辑
|
|
||||||
description: |
|
|
||||||
从请求头 `users_wx_openid` 定位用户,不再从 body 中传 `users_wx_code`。
|
|
||||||
body 中传入 `users_name`、`users_phone_code`、`users_picture`。
|
|
||||||
后端会通过微信 `users_phone_code` 调用官方接口换取真实手机号,再写入 `users_phone`。
|
|
||||||
`users_name`、`users_phone_code`、`users_picture` 均为必填项。
|
|
||||||
当且仅当用户原先这三项资料全部为空,且本次首次完整补充三项资料时,系统自动将 `users_type` 从 `游客` 更新为 `注册用户`。
|
|
||||||
后续资料修改不会再影响已确定的 `users_type`。
|
|
||||||
返回更新后的完整用户信息,其中手机号等敏感字段需脱敏处理。
|
|
||||||
parameters:
|
|
||||||
- in: header
|
|
||||||
name: users_wx_openid
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
description: 微信用户唯一标识,用于数据库查询
|
|
||||||
- in: header
|
|
||||||
name: Authorization
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
description: 标准 JWT 认证头,格式为 `Bearer <token>`
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- users_name
|
|
||||||
- users_phone_code
|
|
||||||
- users_picture
|
|
||||||
properties:
|
|
||||||
users_name:
|
|
||||||
type: string
|
|
||||||
description: 用户姓名
|
|
||||||
example: 张三
|
|
||||||
users_phone_code:
|
|
||||||
type: string
|
|
||||||
description: 微信手机号获取凭证 code
|
|
||||||
example: 2b7d9f2e3c4a5b6d7e8f
|
|
||||||
users_picture:
|
|
||||||
type: string
|
|
||||||
description: 用户头像 URL
|
|
||||||
example: https://example.com/avatar.png
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 信息更新成功
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/ApiResponse'
|
|
||||||
- type: object
|
|
||||||
properties:
|
|
||||||
data:
|
|
||||||
$ref: '#/components/schemas/WechatProfileResponseData'
|
|
||||||
'400':
|
|
||||||
description: 参数错误
|
|
||||||
'401':
|
|
||||||
description: 请求头缺少 users_wx_openid 或 Authorization,或令牌无效
|
|
||||||
'415':
|
|
||||||
description: 请求体格式错误,仅支持 application/json
|
|
||||||
'404':
|
|
||||||
description: 用户不存在
|
|
||||||
'500':
|
|
||||||
description: 服务端异常
|
|
||||||
/api/wechat/refresh-token:
|
|
||||||
post:
|
|
||||||
tags: [微信小程序用户]
|
|
||||||
summary: 微信小程序刷新 token
|
|
||||||
description: |
|
|
||||||
小程序通过请求头中的 `users_wx_openid` 定位用户,并返回新的 JWT token。
|
|
||||||
本接口无 body 参数,请求方法固定为 POST。
|
|
||||||
本接口不要求旧 `Authorization`,仅依赖 `users_wx_openid` 识别用户并签发新 token。
|
|
||||||
parameters:
|
|
||||||
- in: header
|
|
||||||
name: users_wx_openid
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
description: 微信用户唯一标识,用于数据库查询
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 刷新成功
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/ApiResponse'
|
|
||||||
- type: object
|
|
||||||
properties:
|
|
||||||
data:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
token:
|
|
||||||
type: string
|
|
||||||
description: 新的 JWT 令牌
|
|
||||||
'404':
|
|
||||||
description: 未注册用户
|
|
||||||
'401':
|
|
||||||
description: 请求头缺少 users_wx_openid
|
|
||||||
'500':
|
|
||||||
description: 服务端异常
|
|
||||||
28
back-end/dist/src/config/env.js
vendored
28
back-end/dist/src/config/env.js
vendored
@@ -1,28 +0,0 @@
|
|||||||
const dotenv = require('dotenv')
|
|
||||||
|
|
||||||
dotenv.config()
|
|
||||||
|
|
||||||
const requiredEnv = ['WECHAT_APPID', 'WECHAT_SECRET', 'JWT_SECRET']
|
|
||||||
|
|
||||||
for (const key of requiredEnv) {
|
|
||||||
if (!process.env[key]) {
|
|
||||||
console.warn(`[config] 缺少环境变量: ${key}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
nodeEnv: process.env.NODE_ENV || 'development',
|
|
||||||
port: Number(process.env.PORT || 3000),
|
|
||||||
apiPrefix: process.env.API_PREFIX || '/api',
|
|
||||||
appProtocol: process.env.APP_PROTOCOL || 'http',
|
|
||||||
appDomain: process.env.APP_DOMAIN || 'localhost',
|
|
||||||
appBaseUrl:
|
|
||||||
process.env.APP_BASE_URL
|
|
||||||
|| `${process.env.APP_PROTOCOL || 'http'}://${process.env.APP_DOMAIN || 'localhost'}${process.env.PORT ? `:${process.env.PORT}` : ''}`,
|
|
||||||
pocketbaseUrl: process.env.POCKETBASE_API_URL || '',
|
|
||||||
pocketbaseAuthToken: process.env.POCKETBASE_AUTH_TOKEN || '',
|
|
||||||
wechatAppId: process.env.WECHAT_APPID || '',
|
|
||||||
wechatSecret: process.env.WECHAT_SECRET || '',
|
|
||||||
jwtSecret: process.env.JWT_SECRET || 'change_me',
|
|
||||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '2h',
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
const asyncHandler = require('../utils/asyncHandler')
|
|
||||||
const { success } = require('../utils/response')
|
|
||||||
const {
|
|
||||||
validateLoginBody,
|
|
||||||
validateProfileEditBody,
|
|
||||||
} = require('../middlewares/validateWechatAuth')
|
|
||||||
const userService = require('../services/userService')
|
|
||||||
|
|
||||||
const login = asyncHandler(async (req, res) => {
|
|
||||||
const payload = validateLoginBody(req.body)
|
|
||||||
const data = await userService.authenticateWechatUser(payload)
|
|
||||||
|
|
||||||
const messageMap = {
|
|
||||||
register_success: '注册成功',
|
|
||||||
login_success: '登录成功',
|
|
||||||
}
|
|
||||||
|
|
||||||
return success(res, messageMap[data.status] || '登录成功', data)
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateProfile = asyncHandler(async (req, res) => {
|
|
||||||
const payload = validateProfileEditBody(req.body)
|
|
||||||
const data = await userService.updateWechatUserProfile(req.usersWxOpenid, payload)
|
|
||||||
return success(res, '信息更新成功', data)
|
|
||||||
})
|
|
||||||
|
|
||||||
const refreshToken = asyncHandler(async (req, res) => {
|
|
||||||
const data = await userService.refreshWechatToken(req.usersWxOpenid)
|
|
||||||
return success(res, '刷新成功', data)
|
|
||||||
})
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
updateProfile,
|
|
||||||
login,
|
|
||||||
refreshToken,
|
|
||||||
}
|
|
||||||
39
back-end/dist/src/index.js
vendored
39
back-end/dist/src/index.js
vendored
@@ -1,39 +0,0 @@
|
|||||||
const express = require('express')
|
|
||||||
const env = require('./config/env')
|
|
||||||
const { fail } = require('./utils/response')
|
|
||||||
const requestLogger = require('./middlewares/requestLogger')
|
|
||||||
const errorHandler = require('./middlewares/errorHandler')
|
|
||||||
const apiRoutes = require('./routes/apiRoutes')
|
|
||||||
|
|
||||||
function createApp() {
|
|
||||||
const app = express()
|
|
||||||
|
|
||||||
app.use(express.json({ limit: '1mb' }))
|
|
||||||
app.use(requestLogger)
|
|
||||||
|
|
||||||
app.use(env.apiPrefix, apiRoutes)
|
|
||||||
|
|
||||||
app.use((req, res) => {
|
|
||||||
return fail(res, 404, 'Route not found', {
|
|
||||||
path: req.path,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
app.use(errorHandler)
|
|
||||||
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
const app = createApp()
|
|
||||||
app.listen(env.port, () => {
|
|
||||||
console.log(`Server running on port ${env.port}`)
|
|
||||||
console.log(`Public base URL: ${env.appBaseUrl}`)
|
|
||||||
console.log(`Test endpoint: ${env.appBaseUrl}${env.apiPrefix}/test-helloworld`)
|
|
||||||
console.log(`Health check: ${env.appBaseUrl}${env.apiPrefix}/health`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createApp,
|
|
||||||
}
|
|
||||||
22
back-end/dist/src/middlewares/duplicateGuard.js
vendored
22
back-end/dist/src/middlewares/duplicateGuard.js
vendored
@@ -1,22 +0,0 @@
|
|||||||
const AppError = require('../utils/appError')
|
|
||||||
|
|
||||||
const requestCache = new Map()
|
|
||||||
const WINDOW_MS = 5000
|
|
||||||
|
|
||||||
module.exports = function duplicateGuard(req, res, next) {
|
|
||||||
const key = `${req.ip}:${req.originalUrl}:${JSON.stringify(req.body || {})}`
|
|
||||||
const now = Date.now()
|
|
||||||
const lastTime = requestCache.get(key)
|
|
||||||
|
|
||||||
if (lastTime && now - lastTime < WINDOW_MS) {
|
|
||||||
return next(new AppError('请求过于频繁,请稍后重试', 429))
|
|
||||||
}
|
|
||||||
|
|
||||||
requestCache.set(key, now)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
requestCache.delete(key)
|
|
||||||
}, WINDOW_MS)
|
|
||||||
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
19
back-end/dist/src/middlewares/errorHandler.js
vendored
19
back-end/dist/src/middlewares/errorHandler.js
vendored
@@ -1,19 +0,0 @@
|
|||||||
const { fail } = require('../utils/response')
|
|
||||||
const logger = require('../utils/logger')
|
|
||||||
|
|
||||||
module.exports = function errorHandler(err, req, res, next) {
|
|
||||||
logger.error('接口处理异常', {
|
|
||||||
path: req.originalUrl,
|
|
||||||
method: req.method,
|
|
||||||
error: err.message,
|
|
||||||
details: err.details || {},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.headersSent) {
|
|
||||||
return next(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fail(res, err.statusCode || 500, err.message || '服务器内部错误', {
|
|
||||||
...(err.details || {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
20
back-end/dist/src/middlewares/jwtAuth.js
vendored
20
back-end/dist/src/middlewares/jwtAuth.js
vendored
@@ -1,20 +0,0 @@
|
|||||||
const jwt = require('jsonwebtoken')
|
|
||||||
const env = require('../config/env')
|
|
||||||
const AppError = require('../utils/appError')
|
|
||||||
|
|
||||||
module.exports = function jwtAuth(req, res, next) {
|
|
||||||
const authHeader = req.headers.authorization || ''
|
|
||||||
const [scheme, token] = authHeader.split(' ')
|
|
||||||
|
|
||||||
if (scheme !== 'Bearer' || !token) {
|
|
||||||
return next(new AppError('未提供有效的认证令牌', 401))
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(token, env.jwtSecret)
|
|
||||||
req.user = decoded
|
|
||||||
next()
|
|
||||||
} catch {
|
|
||||||
next(new AppError('认证令牌无效或已过期', 401))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
back-end/dist/src/middlewares/requestLogger.js
vendored
17
back-end/dist/src/middlewares/requestLogger.js
vendored
@@ -1,17 +0,0 @@
|
|||||||
const logger = require('../utils/logger')
|
|
||||||
|
|
||||||
module.exports = function requestLogger(req, res, next) {
|
|
||||||
const start = Date.now()
|
|
||||||
|
|
||||||
res.on('finish', () => {
|
|
||||||
logger.info('请求完成', {
|
|
||||||
method: req.method,
|
|
||||||
path: req.originalUrl,
|
|
||||||
statusCode: res.statusCode,
|
|
||||||
durationMs: Date.now() - start,
|
|
||||||
ip: req.ip,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
15
back-end/dist/src/middlewares/requireJsonBody.js
vendored
15
back-end/dist/src/middlewares/requireJsonBody.js
vendored
@@ -1,15 +0,0 @@
|
|||||||
const AppError = require('../utils/appError')
|
|
||||||
|
|
||||||
module.exports = function requireJsonBody(req, res, next) {
|
|
||||||
const methods = ['POST', 'PUT', 'PATCH']
|
|
||||||
|
|
||||||
if (!methods.includes(req.method)) {
|
|
||||||
return next()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.is('application/json')) {
|
|
||||||
return next(new AppError('请求体必须为 application/json', 415))
|
|
||||||
}
|
|
||||||
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
const AppError = require('../utils/appError')
|
|
||||||
|
|
||||||
module.exports = function requireWechatOpenid(req, res, next) {
|
|
||||||
const usersWxOpenid = req.headers['users_wx_openid']
|
|
||||||
|
|
||||||
if (!usersWxOpenid) {
|
|
||||||
return next(new AppError('请求头缺少 users_wx_openid', 401))
|
|
||||||
}
|
|
||||||
|
|
||||||
req.usersWxOpenid = usersWxOpenid
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
const AppError = require('../utils/appError')
|
|
||||||
const { sanitizePayload } = require('../utils/sanitize')
|
|
||||||
|
|
||||||
function validateLoginBody(body = {}) {
|
|
||||||
const payload = sanitizePayload(body)
|
|
||||||
|
|
||||||
if (!payload.users_wx_code) {
|
|
||||||
throw new AppError('users_wx_code 为必填项', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateProfileEditBody(body = {}) {
|
|
||||||
const payload = sanitizePayload(body)
|
|
||||||
|
|
||||||
if (!payload.users_name) {
|
|
||||||
throw new AppError('users_name 为必填项', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.users_phone_code) {
|
|
||||||
throw new AppError('users_phone_code 为必填项', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.users_picture) {
|
|
||||||
throw new AppError('users_picture 为必填项', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
validateLoginBody,
|
|
||||||
validateProfileEditBody,
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
const jwt = require('jsonwebtoken')
|
|
||||||
const env = require('../config/env')
|
|
||||||
const AppError = require('../utils/appError')
|
|
||||||
const requireWechatOpenid = require('./requireWechatOpenid')
|
|
||||||
|
|
||||||
module.exports = function wechatHeadersAuth(req, res, next) {
|
|
||||||
requireWechatOpenid(req, res, (openidError) => {
|
|
||||||
if (openidError) {
|
|
||||||
return next(openidError)
|
|
||||||
}
|
|
||||||
|
|
||||||
const usersWxOpenid = req.usersWxOpenid
|
|
||||||
const authHeader = req.headers.authorization || ''
|
|
||||||
const [scheme, token] = authHeader.split(' ')
|
|
||||||
|
|
||||||
if (scheme !== 'Bearer' || !token) {
|
|
||||||
return next(new AppError('请求头缺少 Authorization', 401))
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(token, env.jwtSecret)
|
|
||||||
if (decoded.users_wx_openid && decoded.users_wx_openid !== usersWxOpenid) {
|
|
||||||
return next(new AppError('请求头中的 users_wx_openid 与令牌不匹配', 401))
|
|
||||||
}
|
|
||||||
req.user = decoded
|
|
||||||
next()
|
|
||||||
} catch {
|
|
||||||
next(new AppError('认证令牌无效或已过期', 401))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
30
back-end/dist/src/routes/apiRoutes.js
vendored
30
back-end/dist/src/routes/apiRoutes.js
vendored
@@ -1,30 +0,0 @@
|
|||||||
const express = require('express')
|
|
||||||
const { success } = require('../utils/response')
|
|
||||||
const { getBuildTimestamp } = require('../utils/buildInfo')
|
|
||||||
const wechatRoutes = require('./wechatRoutes')
|
|
||||||
|
|
||||||
const router = express.Router()
|
|
||||||
|
|
||||||
function respondHelloWorld(req, res) {
|
|
||||||
return success(res, '请求成功', {
|
|
||||||
message: 'Hello, World!',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
status: 'success',
|
|
||||||
build_time: getBuildTimestamp(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function respondHealth(req, res) {
|
|
||||||
return success(res, '服务运行正常', {
|
|
||||||
status: 'healthy',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
router.post('/test-helloworld', respondHelloWorld)
|
|
||||||
|
|
||||||
router.post('/health', respondHealth)
|
|
||||||
|
|
||||||
router.use('/wechat', wechatRoutes)
|
|
||||||
|
|
||||||
module.exports = router
|
|
||||||
14
back-end/dist/src/routes/wechatRoutes.js
vendored
14
back-end/dist/src/routes/wechatRoutes.js
vendored
@@ -1,14 +0,0 @@
|
|||||||
const express = require('express')
|
|
||||||
const duplicateGuard = require('../middlewares/duplicateGuard')
|
|
||||||
const requireJsonBody = require('../middlewares/requireJsonBody')
|
|
||||||
const requireWechatOpenid = require('../middlewares/requireWechatOpenid')
|
|
||||||
const wechatHeadersAuth = require('../middlewares/wechatHeadersAuth')
|
|
||||||
const controller = require('../controllers/wechatController')
|
|
||||||
|
|
||||||
const router = express.Router()
|
|
||||||
|
|
||||||
router.post('/login', requireJsonBody, duplicateGuard, controller.login)
|
|
||||||
router.post('/profile', requireJsonBody, wechatHeadersAuth, duplicateGuard, controller.updateProfile)
|
|
||||||
router.post('/refresh-token', requireWechatOpenid, controller.refreshToken)
|
|
||||||
|
|
||||||
module.exports = router
|
|
||||||
12
back-end/dist/src/services/jwtService.js
vendored
12
back-end/dist/src/services/jwtService.js
vendored
@@ -1,12 +0,0 @@
|
|||||||
const jwt = require('jsonwebtoken')
|
|
||||||
const env = require('../config/env')
|
|
||||||
|
|
||||||
function signAccessToken(payload) {
|
|
||||||
return jwt.sign(payload, env.jwtSecret, {
|
|
||||||
expiresIn: env.jwtExpiresIn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
signAccessToken,
|
|
||||||
}
|
|
||||||
90
back-end/dist/src/services/pocketbaseService.js
vendored
90
back-end/dist/src/services/pocketbaseService.js
vendored
@@ -1,90 +0,0 @@
|
|||||||
const axios = require('axios')
|
|
||||||
const env = require('../config/env')
|
|
||||||
const AppError = require('../utils/appError')
|
|
||||||
|
|
||||||
function getHeaders() {
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env.pocketbaseAuthToken) {
|
|
||||||
headers.Authorization = env.pocketbaseAuthToken.startsWith('Bearer ')
|
|
||||||
? env.pocketbaseAuthToken
|
|
||||||
: `Bearer ${env.pocketbaseAuthToken}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUrl(path) {
|
|
||||||
const base = env.pocketbaseUrl.replace(/\/+$/, '')
|
|
||||||
const normalizedPath = path.replace(/^\/+/, '')
|
|
||||||
return `${base}/${normalizedPath}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request(config) {
|
|
||||||
try {
|
|
||||||
const response = await axios({
|
|
||||||
timeout: 10000,
|
|
||||||
headers: getHeaders(),
|
|
||||||
...config,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
} catch (error) {
|
|
||||||
const detail = error.response?.data || error.message
|
|
||||||
throw new AppError('PocketBase 数据操作失败', 500, {
|
|
||||||
detail,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listUsersByFilter(filter) {
|
|
||||||
const data = await request({
|
|
||||||
method: 'get',
|
|
||||||
url: buildUrl('collections/tbl_users/records'),
|
|
||||||
params: {
|
|
||||||
filter,
|
|
||||||
perPage: 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return data.items || []
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createUser(payload) {
|
|
||||||
return request({
|
|
||||||
method: 'post',
|
|
||||||
url: buildUrl('collections/tbl_users/records'),
|
|
||||||
data: payload,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateUser(recordId, payload) {
|
|
||||||
return request({
|
|
||||||
method: 'patch',
|
|
||||||
url: buildUrl(`collections/tbl_users/records/${recordId}`),
|
|
||||||
data: payload,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCompanyByCompanyId(companyId) {
|
|
||||||
if (!companyId) return null
|
|
||||||
|
|
||||||
const data = await request({
|
|
||||||
method: 'get',
|
|
||||||
url: buildUrl('collections/tbl_company/records'),
|
|
||||||
params: {
|
|
||||||
filter: `company_id = "${companyId}"`,
|
|
||||||
perPage: 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return data.items?.[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
listUsersByFilter,
|
|
||||||
createUser,
|
|
||||||
updateUser,
|
|
||||||
getCompanyByCompanyId,
|
|
||||||
}
|
|
||||||
208
back-end/dist/src/services/userService.js
vendored
208
back-end/dist/src/services/userService.js
vendored
@@ -1,208 +0,0 @@
|
|||||||
const crypto = require('crypto')
|
|
||||||
const AppError = require('../utils/appError')
|
|
||||||
const logger = require('../utils/logger')
|
|
||||||
const wechatService = require('./wechatService')
|
|
||||||
const jwtService = require('./jwtService')
|
|
||||||
const pocketbaseService = require('./pocketbaseService')
|
|
||||||
|
|
||||||
const userMutationLocks = new Map()
|
|
||||||
const GUEST_USER_TYPE = '游客'
|
|
||||||
const REGISTERED_USER_TYPE = '注册用户'
|
|
||||||
|
|
||||||
function buildUserId() {
|
|
||||||
const now = new Date()
|
|
||||||
const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
|
|
||||||
const suffix = crypto.randomInt(1000, 9999)
|
|
||||||
return `U${date}${suffix}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enrichUser(user) {
|
|
||||||
const company = await pocketbaseService.getCompanyByCompanyId(user.company_id)
|
|
||||||
return {
|
|
||||||
pb_id: user.id,
|
|
||||||
users_id: user.users_id,
|
|
||||||
users_type: user.users_type || GUEST_USER_TYPE,
|
|
||||||
users_name: user.users_name,
|
|
||||||
users_phone: user.users_phone,
|
|
||||||
users_phone_masked: maskPhone(user.users_phone),
|
|
||||||
users_picture: user.users_picture,
|
|
||||||
users_wx_openid: user.users_wx_openid,
|
|
||||||
company_id: user.company_id || '',
|
|
||||||
company,
|
|
||||||
created: user.created,
|
|
||||||
updated: user.updated,
|
|
||||||
raw: user,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findUserByOpenid(usersWxOpenid) {
|
|
||||||
const users = await pocketbaseService.listUsersByFilter(`users_wx_openid = "${usersWxOpenid}"`)
|
|
||||||
return users[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskPhone(phone = '') {
|
|
||||||
if (!phone || phone.length < 7) return ''
|
|
||||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInfoComplete(user = {}) {
|
|
||||||
return Boolean(user.users_name && user.users_phone && user.users_picture)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAllProfileFieldsEmpty(user = {}) {
|
|
||||||
return !user.users_name && !user.users_phone && !user.users_picture
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withUserLock(lockKey, handler) {
|
|
||||||
const previous = userMutationLocks.get(lockKey) || Promise.resolve()
|
|
||||||
let release
|
|
||||||
const current = new Promise((resolve) => {
|
|
||||||
release = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
userMutationLocks.set(lockKey, previous.then(() => current))
|
|
||||||
|
|
||||||
await previous
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await handler()
|
|
||||||
} finally {
|
|
||||||
release()
|
|
||||||
if (userMutationLocks.get(lockKey) === current) {
|
|
||||||
userMutationLocks.delete(lockKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authenticateWechatUser(payload) {
|
|
||||||
const openid = await wechatService.getWxOpenId(payload.users_wx_code)
|
|
||||||
return withUserLock(`auth:${openid}`, async () => {
|
|
||||||
const existing = await findUserByOpenid(openid)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
logger.warn('微信注册命中已存在账号', {
|
|
||||||
users_wx_openid: openid,
|
|
||||||
users_type: existing.users_type || GUEST_USER_TYPE,
|
|
||||||
})
|
|
||||||
|
|
||||||
const user = await enrichUser(existing)
|
|
||||||
const token = jwtService.signAccessToken({
|
|
||||||
users_id: user.users_id,
|
|
||||||
users_wx_openid: user.users_wx_openid,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'login_success',
|
|
||||||
is_info_complete: isInfoComplete(existing),
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await pocketbaseService.createUser({
|
|
||||||
users_id: buildUserId(),
|
|
||||||
users_wx_openid: openid,
|
|
||||||
users_type: GUEST_USER_TYPE,
|
|
||||||
})
|
|
||||||
|
|
||||||
const user = await enrichUser(created)
|
|
||||||
const token = jwtService.signAccessToken({
|
|
||||||
users_id: user.users_id,
|
|
||||||
users_wx_openid: user.users_wx_openid,
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info('微信用户注册成功', {
|
|
||||||
users_id: user.users_id,
|
|
||||||
users_phone: user.users_phone,
|
|
||||||
users_type: user.users_type,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'register_success',
|
|
||||||
is_info_complete: false,
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateWechatUserProfile(usersWxOpenid, payload) {
|
|
||||||
return withUserLock(`profile:${usersWxOpenid}`, async () => {
|
|
||||||
const currentUser = await findUserByOpenid(usersWxOpenid)
|
|
||||||
|
|
||||||
if (!currentUser) {
|
|
||||||
throw new AppError('未找到待编辑的用户', 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
const usersPhone = await wechatService.getWxPhoneNumber(payload.users_phone_code)
|
|
||||||
|
|
||||||
if (usersPhone && usersPhone !== currentUser.users_phone) {
|
|
||||||
const samePhoneUsers = await pocketbaseService.listUsersByFilter(`users_phone = "${usersPhone}"`)
|
|
||||||
const phoneUsedByOther = samePhoneUsers.some((item) => item.id !== currentUser.id)
|
|
||||||
if (phoneUsedByOther) {
|
|
||||||
throw new AppError('手机号已被注册', 400)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldPromoteUserType =
|
|
||||||
isAllProfileFieldsEmpty(currentUser)
|
|
||||||
&& payload.users_name
|
|
||||||
&& usersPhone
|
|
||||||
&& payload.users_picture
|
|
||||||
&& (currentUser.users_type === GUEST_USER_TYPE || !currentUser.users_type)
|
|
||||||
|
|
||||||
const updatePayload = {
|
|
||||||
users_name: payload.users_name,
|
|
||||||
users_phone: usersPhone,
|
|
||||||
users_picture: payload.users_picture,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldPromoteUserType) {
|
|
||||||
updatePayload.users_type = REGISTERED_USER_TYPE
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await pocketbaseService.updateUser(currentUser.id, updatePayload)
|
|
||||||
const user = await enrichUser(updated)
|
|
||||||
|
|
||||||
logger.info('微信用户资料更新成功', {
|
|
||||||
users_id: user.users_id,
|
|
||||||
users_phone: user.users_phone,
|
|
||||||
users_type_before: currentUser.users_type || GUEST_USER_TYPE,
|
|
||||||
users_type_after: user.users_type,
|
|
||||||
users_type_promoted: shouldPromoteUserType,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'update_success',
|
|
||||||
user,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshWechatToken(usersWxOpenid) {
|
|
||||||
const userRecord = await findUserByOpenid(usersWxOpenid)
|
|
||||||
|
|
||||||
if (!userRecord) {
|
|
||||||
throw new AppError('未注册用户', 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = jwtService.signAccessToken({
|
|
||||||
users_id: userRecord.users_id,
|
|
||||||
users_wx_openid: userRecord.users_wx_openid,
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info('微信用户刷新令牌成功', {
|
|
||||||
users_id: userRecord.users_id,
|
|
||||||
users_wx_openid: userRecord.users_wx_openid,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
authenticateWechatUser,
|
|
||||||
updateWechatUserProfile,
|
|
||||||
refreshWechatToken,
|
|
||||||
}
|
|
||||||
117
back-end/dist/src/services/wechatService.js
vendored
117
back-end/dist/src/services/wechatService.js
vendored
@@ -1,117 +0,0 @@
|
|||||||
const axios = require('axios')
|
|
||||||
const env = require('../config/env')
|
|
||||||
const AppError = require('../utils/appError')
|
|
||||||
|
|
||||||
let accessTokenCache = {
|
|
||||||
token: '',
|
|
||||||
expiresAt: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWxOpenId(code) {
|
|
||||||
if (!env.wechatAppId || !env.wechatSecret) {
|
|
||||||
throw new AppError('微信小程序配置缺失', 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = 'https://api.weixin.qq.com/sns/jscode2session'
|
|
||||||
const response = await axios.get(url, {
|
|
||||||
params: {
|
|
||||||
appid: env.wechatAppId,
|
|
||||||
secret: env.wechatSecret,
|
|
||||||
js_code: code,
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
},
|
|
||||||
timeout: 10000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = response.data || {}
|
|
||||||
|
|
||||||
if (data.openid) {
|
|
||||||
return data.openid
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.errcode || data.errmsg) {
|
|
||||||
throw new AppError(`获取OpenID失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
|
|
||||||
wechat: data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AppError('获取OpenID失败: 响应中未包含openid', 502)
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AppError) throw error
|
|
||||||
throw new AppError(`获取微信OpenID时发生错误: ${error.message}`, 502)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWechatAccessToken() {
|
|
||||||
if (accessTokenCache.token && Date.now() < accessTokenCache.expiresAt) {
|
|
||||||
return accessTokenCache.token
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
|
|
||||||
params: {
|
|
||||||
grant_type: 'client_credential',
|
|
||||||
appid: env.wechatAppId,
|
|
||||||
secret: env.wechatSecret,
|
|
||||||
},
|
|
||||||
timeout: 10000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = response.data || {}
|
|
||||||
|
|
||||||
if (!data.access_token) {
|
|
||||||
throw new AppError(`获取微信 access_token 失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
|
|
||||||
wechat: data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
accessTokenCache = {
|
|
||||||
token: data.access_token,
|
|
||||||
expiresAt: Date.now() + Math.max((data.expires_in || 7200) - 300, 60) * 1000,
|
|
||||||
}
|
|
||||||
|
|
||||||
return accessTokenCache.token
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AppError) throw error
|
|
||||||
throw new AppError(`获取微信 access_token 时发生错误: ${error.message}`, 502)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWxPhoneNumber(phoneCode) {
|
|
||||||
const accessToken = await getWechatAccessToken()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(
|
|
||||||
`https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`,
|
|
||||||
{
|
|
||||||
code: phoneCode,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
timeout: 10000,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const data = response.data || {}
|
|
||||||
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber
|
|
||||||
|
|
||||||
if (phone) {
|
|
||||||
return phone
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AppError(`获取微信手机号失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
|
|
||||||
wechat: data,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AppError) throw error
|
|
||||||
throw new AppError(`获取微信手机号时发生错误: ${error.message}`, 502)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getWxOpenId,
|
|
||||||
getWxPhoneNumber,
|
|
||||||
}
|
|
||||||
10
back-end/dist/src/utils/appError.js
vendored
10
back-end/dist/src/utils/appError.js
vendored
@@ -1,10 +0,0 @@
|
|||||||
class AppError extends Error {
|
|
||||||
constructor(message, statusCode = 500, details = {}) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'AppError'
|
|
||||||
this.statusCode = statusCode
|
|
||||||
this.details = details
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = AppError
|
|
||||||
5
back-end/dist/src/utils/asyncHandler.js
vendored
5
back-end/dist/src/utils/asyncHandler.js
vendored
@@ -1,5 +0,0 @@
|
|||||||
module.exports = function asyncHandler(fn) {
|
|
||||||
return function wrappedHandler(req, res, next) {
|
|
||||||
Promise.resolve(fn(req, res, next)).catch(next)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
30
back-end/dist/src/utils/buildInfo.js
vendored
30
back-end/dist/src/utils/buildInfo.js
vendored
@@ -1,30 +0,0 @@
|
|||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
|
|
||||||
function getBuildInfoFilePath() {
|
|
||||||
return path.resolve(__dirname, '..', '..', 'build-info.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBuildTimestamp() {
|
|
||||||
if (process.env.BUILD_TIME) {
|
|
||||||
return process.env.BUILD_TIME
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = getBuildInfoFilePath()
|
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf8')
|
|
||||||
const payload = JSON.parse(content)
|
|
||||||
return payload.buildTime || null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getBuildTimestamp,
|
|
||||||
}
|
|
||||||
27
back-end/dist/src/utils/logger.js
vendored
27
back-end/dist/src/utils/logger.js
vendored
@@ -1,27 +0,0 @@
|
|||||||
function createLog(method, message, meta = {}) {
|
|
||||||
const payload = {
|
|
||||||
time: new Date().toISOString(),
|
|
||||||
method,
|
|
||||||
message,
|
|
||||||
...meta,
|
|
||||||
}
|
|
||||||
return JSON.stringify(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
function info(message, meta) {
|
|
||||||
console.log(createLog('INFO', message, meta))
|
|
||||||
}
|
|
||||||
|
|
||||||
function warn(message, meta) {
|
|
||||||
console.warn(createLog('WARN', message, meta))
|
|
||||||
}
|
|
||||||
|
|
||||||
function error(message, meta) {
|
|
||||||
console.error(createLog('ERROR', message, meta))
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
info,
|
|
||||||
warn,
|
|
||||||
error,
|
|
||||||
}
|
|
||||||
20
back-end/dist/src/utils/response.js
vendored
20
back-end/dist/src/utils/response.js
vendored
@@ -1,20 +0,0 @@
|
|||||||
function success(res, msg = '操作成功', data = {}, code = 200) {
|
|
||||||
return res.status(code).json({
|
|
||||||
code,
|
|
||||||
msg,
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function fail(res, code = 500, msg = '服务器内部错误', data = {}) {
|
|
||||||
return res.status(code).json({
|
|
||||||
code,
|
|
||||||
msg,
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
success,
|
|
||||||
fail,
|
|
||||||
}
|
|
||||||
17
back-end/dist/src/utils/sanitize.js
vendored
17
back-end/dist/src/utils/sanitize.js
vendored
@@ -1,17 +0,0 @@
|
|||||||
function sanitizeString(value) {
|
|
||||||
if (typeof value !== 'string') return ''
|
|
||||||
return value.replace(/[<>\\]/g, '').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizePayload(payload = {}) {
|
|
||||||
return Object.keys(payload).reduce((acc, key) => {
|
|
||||||
const value = payload[key]
|
|
||||||
acc[key] = typeof value === 'string' ? sanitizeString(value) : value
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
sanitizeString,
|
|
||||||
sanitizePayload,
|
|
||||||
}
|
|
||||||
@@ -14,10 +14,8 @@
|
|||||||
"spec:validate": "npx @fission-ai/openspec validate spec/"
|
"spec:validate": "npx @fission-ai/openspec validate spec/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1"
|
||||||
"jsonwebtoken": "^9.0.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fission-ai/openspec": "^1.0.0",
|
"@fission-ai/openspec": "^1.0.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: BAI Management API
|
title: BAI Management API
|
||||||
description: BAI 管理系统后端 API 文档
|
description: 精简后的后端 API,仅保留健康检查接口
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
servers:
|
servers:
|
||||||
- url: https://bai-api.blv-oa.com
|
- url: https://bai-api.blv-oa.com
|
||||||
@@ -11,14 +11,7 @@ servers:
|
|||||||
tags:
|
tags:
|
||||||
- name: 系统
|
- name: 系统
|
||||||
description: 基础健康检查接口
|
description: 基础健康检查接口
|
||||||
- name: 微信小程序用户
|
|
||||||
description: 微信小程序注册、登录与鉴权接口
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
|
||||||
bearerAuth:
|
|
||||||
type: http
|
|
||||||
scheme: bearer
|
|
||||||
bearerFormat: JWT
|
|
||||||
schemas:
|
schemas:
|
||||||
ApiResponse:
|
ApiResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -26,15 +19,12 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
code:
|
code:
|
||||||
type: integer
|
type: integer
|
||||||
description: 业务状态码
|
|
||||||
example: 200
|
example: 200
|
||||||
msg:
|
msg:
|
||||||
type: string
|
type: string
|
||||||
description: 响应消息
|
|
||||||
example: 操作成功
|
example: 操作成功
|
||||||
data:
|
data:
|
||||||
type: object
|
type: object
|
||||||
description: 响应数据
|
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
HealthData:
|
HealthData:
|
||||||
type: object
|
type: object
|
||||||
@@ -45,176 +35,7 @@ components:
|
|||||||
timestamp:
|
timestamp:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
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
|
|
||||||
description: 最近一次构建时间;开发模式下若未执行构建则可能为空
|
|
||||||
format: date-time
|
|
||||||
WechatProfileRequest:
|
|
||||||
type: object
|
|
||||||
required: [users_name, users_phone_code, users_picture]
|
|
||||||
properties:
|
|
||||||
users_name:
|
|
||||||
type: string
|
|
||||||
description: 用户姓名
|
|
||||||
example: 张三
|
|
||||||
users_phone_code:
|
|
||||||
type: string
|
|
||||||
description: 微信手机号获取凭证 code,由后端换取真实手机号后写入 users_phone
|
|
||||||
example: 2b7d9f2e3c4a5b6d7e8f
|
|
||||||
users_picture:
|
|
||||||
type: string
|
|
||||||
description: 用户头像 URL
|
|
||||||
example: https://example.com/avatar.png
|
|
||||||
WechatAuthRequest:
|
|
||||||
type: object
|
|
||||||
required: [users_wx_code]
|
|
||||||
properties:
|
|
||||||
users_wx_code:
|
|
||||||
type: string
|
|
||||||
description: 微信小程序登录临时凭证 code
|
|
||||||
example: 0a1b2c3d4e5f6g
|
|
||||||
CompanyInfo:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
company_id:
|
|
||||||
type: string
|
|
||||||
example: C10001
|
|
||||||
company_name:
|
|
||||||
type: string
|
|
||||||
example: 示例科技有限公司
|
|
||||||
company_type:
|
|
||||||
type: string
|
|
||||||
example: 科技服务
|
|
||||||
company_entity:
|
|
||||||
type: string
|
|
||||||
example: 李四
|
|
||||||
company_usci:
|
|
||||||
type: string
|
|
||||||
example: 91330100XXXXXXXXXX
|
|
||||||
company_nationality:
|
|
||||||
type: string
|
|
||||||
example: 中国
|
|
||||||
company_province:
|
|
||||||
type: string
|
|
||||||
example: 浙江省
|
|
||||||
company_city:
|
|
||||||
type: string
|
|
||||||
example: 杭州市
|
|
||||||
company_postalcode:
|
|
||||||
type: string
|
|
||||||
example: "310000"
|
|
||||||
company_add:
|
|
||||||
type: string
|
|
||||||
example: 某某大道 100 号
|
|
||||||
company_status:
|
|
||||||
type: string
|
|
||||||
example: 正常
|
|
||||||
company_level:
|
|
||||||
type: string
|
|
||||||
example: A
|
|
||||||
company_remark:
|
|
||||||
type: string
|
|
||||||
example: 重点合作客户
|
|
||||||
UserInfo:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
users_id:
|
|
||||||
type: string
|
|
||||||
example: U202603190001
|
|
||||||
users_type:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
用户类型。
|
|
||||||
- `游客`:仅完成微信新号注册,尚未首次完整补充 `users_name`、`users_phone`、`users_picture`
|
|
||||||
- `注册用户`:用户曾从“三项资料均为空”首次补充为“三项资料均完整”
|
|
||||||
enum: [游客, 注册用户]
|
|
||||||
example: 游客
|
|
||||||
users_name:
|
|
||||||
type: string
|
|
||||||
example: 张三
|
|
||||||
users_phone:
|
|
||||||
type: string
|
|
||||||
example: "13800138000"
|
|
||||||
users_phone_masked:
|
|
||||||
type: string
|
|
||||||
example: "138****8000"
|
|
||||||
users_picture:
|
|
||||||
type: string
|
|
||||||
example: https://example.com/avatar.png
|
|
||||||
users_wx_openid:
|
|
||||||
type: string
|
|
||||||
example: oAbCdEfGh123456789
|
|
||||||
company_id:
|
|
||||||
type: string
|
|
||||||
example: C10001
|
|
||||||
company:
|
|
||||||
$ref: '#/components/schemas/CompanyInfo'
|
|
||||||
pb_id:
|
|
||||||
type: string
|
|
||||||
description: PocketBase 记录 id
|
|
||||||
example: abc123xyz
|
|
||||||
created:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
updated:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
WechatProfileResponseData:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
description: 信息编辑状态
|
|
||||||
enum: [update_success, update_failed]
|
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserInfo'
|
|
||||||
WechatAuthResponseData:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
description: 登录/注册结果状态
|
|
||||||
enum: [register_success, login_success]
|
|
||||||
is_info_complete:
|
|
||||||
type: boolean
|
|
||||||
description: 用户资料是否已完善
|
|
||||||
example: true
|
|
||||||
token:
|
|
||||||
type: string
|
|
||||||
description: JWT 令牌
|
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserInfo'
|
|
||||||
paths:
|
paths:
|
||||||
/api/test-helloworld:
|
|
||||||
post:
|
|
||||||
tags: [系统]
|
|
||||||
summary: Test endpoint
|
|
||||||
description: Returns a hello world message and build time
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Successful response
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/ApiResponse'
|
|
||||||
- type: object
|
|
||||||
properties:
|
|
||||||
data:
|
|
||||||
$ref: '#/components/schemas/HelloWorldData'
|
|
||||||
/api/health:
|
/api/health:
|
||||||
post:
|
post:
|
||||||
tags: [系统]
|
tags: [系统]
|
||||||
@@ -232,151 +53,5 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/HealthData'
|
$ref: '#/components/schemas/HealthData'
|
||||||
/api/wechat/login:
|
|
||||||
post:
|
|
||||||
tags: [微信小程序用户]
|
|
||||||
summary: 微信小程序登录/注册合一
|
|
||||||
description: |
|
|
||||||
使用微信小程序临时登录凭证换取 openid。
|
|
||||||
若用户不存在,则自动创建新账号并返回完整用户信息;若已存在,则直接登录并返回完整用户信息。
|
|
||||||
登录/注册合一接口不处理手机号获取。
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- users_wx_code
|
|
||||||
properties:
|
|
||||||
users_wx_code:
|
|
||||||
type: string
|
|
||||||
description: 微信小程序登录临时凭证 code
|
|
||||||
example: 0a1b2c3d4e5f6g
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 登录或注册成功
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/ApiResponse'
|
|
||||||
- type: object
|
|
||||||
properties:
|
properties:
|
||||||
data:
|
|
||||||
$ref: '#/components/schemas/WechatAuthResponseData'
|
|
||||||
'400':
|
|
||||||
description: 参数错误
|
|
||||||
'415':
|
|
||||||
description: 请求体格式错误,仅支持 application/json
|
|
||||||
'429':
|
|
||||||
description: 重复提交过于频繁
|
|
||||||
'500':
|
|
||||||
description: 服务端异常
|
|
||||||
/api/wechat/profile:
|
|
||||||
post:
|
|
||||||
tags: [微信小程序用户]
|
|
||||||
summary: 微信小程序用户信息编辑
|
|
||||||
description: |
|
|
||||||
从请求头 `users_wx_openid` 定位用户,不再从 body 中传 `users_wx_code`。
|
|
||||||
body 中传入 `users_name`、`users_phone_code`、`users_picture`。
|
|
||||||
后端会通过微信 `users_phone_code` 调用官方接口换取真实手机号,再写入 `users_phone`。
|
|
||||||
`users_name`、`users_phone_code`、`users_picture` 均为必填项。
|
|
||||||
当且仅当用户原先这三项资料全部为空,且本次首次完整补充三项资料时,系统自动将 `users_type` 从 `游客` 更新为 `注册用户`。
|
|
||||||
后续资料修改不会再影响已确定的 `users_type`。
|
|
||||||
返回更新后的完整用户信息,其中手机号等敏感字段需脱敏处理。
|
|
||||||
parameters:
|
|
||||||
- in: header
|
|
||||||
name: users_wx_openid
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
description: 微信用户唯一标识,用于数据库查询
|
|
||||||
- in: header
|
|
||||||
name: Authorization
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
description: 标准 JWT 认证头,格式为 `Bearer <token>`
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- users_name
|
|
||||||
- users_phone_code
|
|
||||||
- users_picture
|
|
||||||
properties:
|
|
||||||
users_name:
|
|
||||||
type: string
|
|
||||||
description: 用户姓名
|
|
||||||
example: 张三
|
|
||||||
users_phone_code:
|
|
||||||
type: string
|
|
||||||
description: 微信手机号获取凭证 code
|
|
||||||
example: 2b7d9f2e3c4a5b6d7e8f
|
|
||||||
users_picture:
|
|
||||||
type: string
|
|
||||||
description: 用户头像 URL
|
|
||||||
example: https://example.com/avatar.png
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 信息更新成功
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/ApiResponse'
|
|
||||||
- type: object
|
|
||||||
properties:
|
|
||||||
data:
|
|
||||||
$ref: '#/components/schemas/WechatProfileResponseData'
|
|
||||||
'400':
|
|
||||||
description: 参数错误
|
|
||||||
'401':
|
|
||||||
description: 请求头缺少 users_wx_openid 或 Authorization,或令牌无效
|
|
||||||
'415':
|
|
||||||
description: 请求体格式错误,仅支持 application/json
|
|
||||||
'404':
|
|
||||||
description: 用户不存在
|
|
||||||
'500':
|
|
||||||
description: 服务端异常
|
|
||||||
/api/wechat/refresh-token:
|
|
||||||
post:
|
|
||||||
tags: [微信小程序用户]
|
|
||||||
summary: 微信小程序刷新 token
|
|
||||||
description: |
|
|
||||||
小程序通过请求头中的 `users_wx_openid` 定位用户,并返回新的 JWT token。
|
|
||||||
本接口无 body 参数,请求方法固定为 POST。
|
|
||||||
本接口不要求旧 `Authorization`,仅依赖 `users_wx_openid` 识别用户并签发新 token。
|
|
||||||
parameters:
|
|
||||||
- in: header
|
|
||||||
name: users_wx_openid
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
description: 微信用户唯一标识,用于数据库查询
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 刷新成功
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/ApiResponse'
|
|
||||||
- type: object
|
|
||||||
properties:
|
|
||||||
data:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
token:
|
|
||||||
type: string
|
|
||||||
description: 新的 JWT 令牌
|
|
||||||
'404':
|
|
||||||
description: 未注册用户
|
|
||||||
'401':
|
|
||||||
description: 请求头缺少 users_wx_openid
|
|
||||||
'500':
|
|
||||||
description: 服务端异常
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const dotenv = require('dotenv')
|
|||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
const requiredEnv = ['WECHAT_APPID', 'WECHAT_SECRET', 'JWT_SECRET']
|
const requiredEnv = []
|
||||||
|
|
||||||
for (const key of requiredEnv) {
|
for (const key of requiredEnv) {
|
||||||
if (!process.env[key]) {
|
if (!process.env[key]) {
|
||||||
@@ -21,8 +21,4 @@ module.exports = {
|
|||||||
|| `${process.env.APP_PROTOCOL || 'http'}://${process.env.APP_DOMAIN || 'localhost'}${process.env.PORT ? `:${process.env.PORT}` : ''}`,
|
|| `${process.env.APP_PROTOCOL || 'http'}://${process.env.APP_DOMAIN || 'localhost'}${process.env.PORT ? `:${process.env.PORT}` : ''}`,
|
||||||
pocketbaseUrl: process.env.POCKETBASE_API_URL || '',
|
pocketbaseUrl: process.env.POCKETBASE_API_URL || '',
|
||||||
pocketbaseAuthToken: process.env.POCKETBASE_AUTH_TOKEN || '',
|
pocketbaseAuthToken: process.env.POCKETBASE_AUTH_TOKEN || '',
|
||||||
wechatAppId: process.env.WECHAT_APPID || '',
|
|
||||||
wechatSecret: process.env.WECHAT_SECRET || '',
|
|
||||||
jwtSecret: process.env.JWT_SECRET || 'change_me',
|
|
||||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '2h',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
const asyncHandler = require('../utils/asyncHandler')
|
|
||||||
const { success } = require('../utils/response')
|
|
||||||
const {
|
|
||||||
validateLoginBody,
|
|
||||||
validateProfileEditBody,
|
|
||||||
} = require('../middlewares/validateWechatAuth')
|
|
||||||
const userService = require('../services/userService')
|
|
||||||
|
|
||||||
const login = asyncHandler(async (req, res) => {
|
|
||||||
const payload = validateLoginBody(req.body)
|
|
||||||
const data = await userService.authenticateWechatUser(payload)
|
|
||||||
|
|
||||||
const messageMap = {
|
|
||||||
register_success: '注册成功',
|
|
||||||
login_success: '登录成功',
|
|
||||||
}
|
|
||||||
|
|
||||||
return success(res, messageMap[data.status] || '登录成功', data)
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateProfile = asyncHandler(async (req, res) => {
|
|
||||||
const payload = validateProfileEditBody(req.body)
|
|
||||||
const data = await userService.updateWechatUserProfile(req.usersWxOpenid, payload)
|
|
||||||
return success(res, '信息更新成功', data)
|
|
||||||
})
|
|
||||||
|
|
||||||
const refreshToken = asyncHandler(async (req, res) => {
|
|
||||||
const data = await userService.refreshWechatToken(req.usersWxOpenid)
|
|
||||||
return success(res, '刷新成功', data)
|
|
||||||
})
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
updateProfile,
|
|
||||||
login,
|
|
||||||
refreshToken,
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,6 @@ if (require.main === module) {
|
|||||||
app.listen(env.port, () => {
|
app.listen(env.port, () => {
|
||||||
console.log(`Server running on port ${env.port}`)
|
console.log(`Server running on port ${env.port}`)
|
||||||
console.log(`Public base URL: ${env.appBaseUrl}`)
|
console.log(`Public base URL: ${env.appBaseUrl}`)
|
||||||
console.log(`Test endpoint: ${env.appBaseUrl}${env.apiPrefix}/test-helloworld`)
|
|
||||||
console.log(`Health check: ${env.appBaseUrl}${env.apiPrefix}/health`)
|
console.log(`Health check: ${env.appBaseUrl}${env.apiPrefix}/health`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
const AppError = require('../utils/appError')
|
|
||||||
|
|
||||||
const requestCache = new Map()
|
|
||||||
const WINDOW_MS = 5000
|
|
||||||
|
|
||||||
module.exports = function duplicateGuard(req, res, next) {
|
|
||||||
const key = `${req.ip}:${req.originalUrl}:${JSON.stringify(req.body || {})}`
|
|
||||||
const now = Date.now()
|
|
||||||
const lastTime = requestCache.get(key)
|
|
||||||
|
|
||||||
if (lastTime && now - lastTime < WINDOW_MS) {
|
|
||||||
return next(new AppError('请求过于频繁,请稍后重试', 429))
|
|
||||||
}
|
|
||||||
|
|
||||||
requestCache.set(key, now)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
requestCache.delete(key)
|
|
||||||
}, WINDOW_MS)
|
|
||||||
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
const jwt = require('jsonwebtoken')
|
|
||||||
const env = require('../config/env')
|
|
||||||
const AppError = require('../utils/appError')
|
|
||||||
|
|
||||||
module.exports = function jwtAuth(req, res, next) {
|
|
||||||
const authHeader = req.headers.authorization || ''
|
|
||||||
const [scheme, token] = authHeader.split(' ')
|
|
||||||
|
|
||||||
if (scheme !== 'Bearer' || !token) {
|
|
||||||
return next(new AppError('未提供有效的认证令牌', 401))
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(token, env.jwtSecret)
|
|
||||||
req.user = decoded
|
|
||||||
next()
|
|
||||||
} catch {
|
|
||||||
next(new AppError('认证令牌无效或已过期', 401))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
const AppError = require('../utils/appError')
|
|
||||||
|
|
||||||
module.exports = function requireJsonBody(req, res, next) {
|
|
||||||
const methods = ['POST', 'PUT', 'PATCH']
|
|
||||||
|
|
||||||
if (!methods.includes(req.method)) {
|
|
||||||
return next()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.is('application/json')) {
|
|
||||||
return next(new AppError('请求体必须为 application/json', 415))
|
|
||||||
}
|
|
||||||
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
const AppError = require('../utils/appError')
|
|
||||||
|
|
||||||
module.exports = function requireWechatOpenid(req, res, next) {
|
|
||||||
const usersWxOpenid = req.headers['users_wx_openid']
|
|
||||||
|
|
||||||
if (!usersWxOpenid) {
|
|
||||||
return next(new AppError('请求头缺少 users_wx_openid', 401))
|
|
||||||
}
|
|
||||||
|
|
||||||
req.usersWxOpenid = usersWxOpenid
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
const AppError = require('../utils/appError')
|
|
||||||
const { sanitizePayload } = require('../utils/sanitize')
|
|
||||||
|
|
||||||
function validateLoginBody(body = {}) {
|
|
||||||
const payload = sanitizePayload(body)
|
|
||||||
|
|
||||||
if (!payload.users_wx_code) {
|
|
||||||
throw new AppError('users_wx_code 为必填项', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateProfileEditBody(body = {}) {
|
|
||||||
const payload = sanitizePayload(body)
|
|
||||||
|
|
||||||
if (!payload.users_name) {
|
|
||||||
throw new AppError('users_name 为必填项', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.users_phone_code) {
|
|
||||||
throw new AppError('users_phone_code 为必填项', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.users_picture) {
|
|
||||||
throw new AppError('users_picture 为必填项', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
validateLoginBody,
|
|
||||||
validateProfileEditBody,
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
const jwt = require('jsonwebtoken')
|
|
||||||
const env = require('../config/env')
|
|
||||||
const AppError = require('../utils/appError')
|
|
||||||
const requireWechatOpenid = require('./requireWechatOpenid')
|
|
||||||
|
|
||||||
module.exports = function wechatHeadersAuth(req, res, next) {
|
|
||||||
requireWechatOpenid(req, res, (openidError) => {
|
|
||||||
if (openidError) {
|
|
||||||
return next(openidError)
|
|
||||||
}
|
|
||||||
|
|
||||||
const usersWxOpenid = req.usersWxOpenid
|
|
||||||
const authHeader = req.headers.authorization || ''
|
|
||||||
const [scheme, token] = authHeader.split(' ')
|
|
||||||
|
|
||||||
if (scheme !== 'Bearer' || !token) {
|
|
||||||
return next(new AppError('请求头缺少 Authorization', 401))
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(token, env.jwtSecret)
|
|
||||||
if (decoded.users_wx_openid && decoded.users_wx_openid !== usersWxOpenid) {
|
|
||||||
return next(new AppError('请求头中的 users_wx_openid 与令牌不匹配', 401))
|
|
||||||
}
|
|
||||||
req.user = decoded
|
|
||||||
next()
|
|
||||||
} catch {
|
|
||||||
next(new AppError('认证令牌无效或已过期', 401))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,8 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const { success } = require('../utils/response')
|
const { success } = require('../utils/response')
|
||||||
const { getBuildTimestamp } = require('../utils/buildInfo')
|
|
||||||
const wechatRoutes = require('./wechatRoutes')
|
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
function respondHelloWorld(req, res) {
|
|
||||||
return success(res, '请求成功', {
|
|
||||||
message: 'Hello, World!',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
status: 'success',
|
|
||||||
build_time: getBuildTimestamp(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function respondHealth(req, res) {
|
function respondHealth(req, res) {
|
||||||
return success(res, '服务运行正常', {
|
return success(res, '服务运行正常', {
|
||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
@@ -21,10 +10,6 @@ function respondHealth(req, res) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post('/test-helloworld', respondHelloWorld)
|
|
||||||
|
|
||||||
router.post('/health', respondHealth)
|
router.post('/health', respondHealth)
|
||||||
|
|
||||||
router.use('/wechat', wechatRoutes)
|
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
const express = require('express')
|
|
||||||
const duplicateGuard = require('../middlewares/duplicateGuard')
|
|
||||||
const requireJsonBody = require('../middlewares/requireJsonBody')
|
|
||||||
const requireWechatOpenid = require('../middlewares/requireWechatOpenid')
|
|
||||||
const wechatHeadersAuth = require('../middlewares/wechatHeadersAuth')
|
|
||||||
const controller = require('../controllers/wechatController')
|
|
||||||
|
|
||||||
const router = express.Router()
|
|
||||||
|
|
||||||
router.post('/login', requireJsonBody, duplicateGuard, controller.login)
|
|
||||||
router.post('/profile', requireJsonBody, wechatHeadersAuth, duplicateGuard, controller.updateProfile)
|
|
||||||
router.post('/refresh-token', requireWechatOpenid, controller.refreshToken)
|
|
||||||
|
|
||||||
module.exports = router
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
const jwt = require('jsonwebtoken')
|
|
||||||
const env = require('../config/env')
|
|
||||||
|
|
||||||
function signAccessToken(payload) {
|
|
||||||
return jwt.sign(payload, env.jwtSecret, {
|
|
||||||
expiresIn: env.jwtExpiresIn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
signAccessToken,
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
const crypto = require('crypto')
|
|
||||||
const AppError = require('../utils/appError')
|
|
||||||
const logger = require('../utils/logger')
|
|
||||||
const wechatService = require('./wechatService')
|
|
||||||
const jwtService = require('./jwtService')
|
|
||||||
const pocketbaseService = require('./pocketbaseService')
|
|
||||||
|
|
||||||
const userMutationLocks = new Map()
|
|
||||||
const GUEST_USER_TYPE = '游客'
|
|
||||||
const REGISTERED_USER_TYPE = '注册用户'
|
|
||||||
|
|
||||||
function buildUserId() {
|
|
||||||
const now = new Date()
|
|
||||||
const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
|
|
||||||
const suffix = crypto.randomInt(1000, 9999)
|
|
||||||
return `U${date}${suffix}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enrichUser(user) {
|
|
||||||
const company = await pocketbaseService.getCompanyByCompanyId(user.company_id)
|
|
||||||
return {
|
|
||||||
pb_id: user.id,
|
|
||||||
users_id: user.users_id,
|
|
||||||
users_type: user.users_type || GUEST_USER_TYPE,
|
|
||||||
users_name: user.users_name,
|
|
||||||
users_phone: user.users_phone,
|
|
||||||
users_phone_masked: maskPhone(user.users_phone),
|
|
||||||
users_picture: user.users_picture,
|
|
||||||
users_wx_openid: user.users_wx_openid,
|
|
||||||
company_id: user.company_id || '',
|
|
||||||
company,
|
|
||||||
created: user.created,
|
|
||||||
updated: user.updated,
|
|
||||||
raw: user,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findUserByOpenid(usersWxOpenid) {
|
|
||||||
const users = await pocketbaseService.listUsersByFilter(`users_wx_openid = "${usersWxOpenid}"`)
|
|
||||||
return users[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskPhone(phone = '') {
|
|
||||||
if (!phone || phone.length < 7) return ''
|
|
||||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInfoComplete(user = {}) {
|
|
||||||
return Boolean(user.users_name && user.users_phone && user.users_picture)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAllProfileFieldsEmpty(user = {}) {
|
|
||||||
return !user.users_name && !user.users_phone && !user.users_picture
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withUserLock(lockKey, handler) {
|
|
||||||
const previous = userMutationLocks.get(lockKey) || Promise.resolve()
|
|
||||||
let release
|
|
||||||
const current = new Promise((resolve) => {
|
|
||||||
release = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
userMutationLocks.set(lockKey, previous.then(() => current))
|
|
||||||
|
|
||||||
await previous
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await handler()
|
|
||||||
} finally {
|
|
||||||
release()
|
|
||||||
if (userMutationLocks.get(lockKey) === current) {
|
|
||||||
userMutationLocks.delete(lockKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authenticateWechatUser(payload) {
|
|
||||||
const openid = await wechatService.getWxOpenId(payload.users_wx_code)
|
|
||||||
return withUserLock(`auth:${openid}`, async () => {
|
|
||||||
const existing = await findUserByOpenid(openid)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
logger.warn('微信注册命中已存在账号', {
|
|
||||||
users_wx_openid: openid,
|
|
||||||
users_type: existing.users_type || GUEST_USER_TYPE,
|
|
||||||
})
|
|
||||||
|
|
||||||
const user = await enrichUser(existing)
|
|
||||||
const token = jwtService.signAccessToken({
|
|
||||||
users_id: user.users_id,
|
|
||||||
users_wx_openid: user.users_wx_openid,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'login_success',
|
|
||||||
is_info_complete: isInfoComplete(existing),
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await pocketbaseService.createUser({
|
|
||||||
users_id: buildUserId(),
|
|
||||||
users_wx_openid: openid,
|
|
||||||
users_type: GUEST_USER_TYPE,
|
|
||||||
})
|
|
||||||
|
|
||||||
const user = await enrichUser(created)
|
|
||||||
const token = jwtService.signAccessToken({
|
|
||||||
users_id: user.users_id,
|
|
||||||
users_wx_openid: user.users_wx_openid,
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info('微信用户注册成功', {
|
|
||||||
users_id: user.users_id,
|
|
||||||
users_phone: user.users_phone,
|
|
||||||
users_type: user.users_type,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'register_success',
|
|
||||||
is_info_complete: false,
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateWechatUserProfile(usersWxOpenid, payload) {
|
|
||||||
return withUserLock(`profile:${usersWxOpenid}`, async () => {
|
|
||||||
const currentUser = await findUserByOpenid(usersWxOpenid)
|
|
||||||
|
|
||||||
if (!currentUser) {
|
|
||||||
throw new AppError('未找到待编辑的用户', 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
const usersPhone = await wechatService.getWxPhoneNumber(payload.users_phone_code)
|
|
||||||
|
|
||||||
if (usersPhone && usersPhone !== currentUser.users_phone) {
|
|
||||||
const samePhoneUsers = await pocketbaseService.listUsersByFilter(`users_phone = "${usersPhone}"`)
|
|
||||||
const phoneUsedByOther = samePhoneUsers.some((item) => item.id !== currentUser.id)
|
|
||||||
if (phoneUsedByOther) {
|
|
||||||
throw new AppError('手机号已被注册', 400)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldPromoteUserType =
|
|
||||||
isAllProfileFieldsEmpty(currentUser)
|
|
||||||
&& payload.users_name
|
|
||||||
&& usersPhone
|
|
||||||
&& payload.users_picture
|
|
||||||
&& (currentUser.users_type === GUEST_USER_TYPE || !currentUser.users_type)
|
|
||||||
|
|
||||||
const updatePayload = {
|
|
||||||
users_name: payload.users_name,
|
|
||||||
users_phone: usersPhone,
|
|
||||||
users_picture: payload.users_picture,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldPromoteUserType) {
|
|
||||||
updatePayload.users_type = REGISTERED_USER_TYPE
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await pocketbaseService.updateUser(currentUser.id, updatePayload)
|
|
||||||
const user = await enrichUser(updated)
|
|
||||||
|
|
||||||
logger.info('微信用户资料更新成功', {
|
|
||||||
users_id: user.users_id,
|
|
||||||
users_phone: user.users_phone,
|
|
||||||
users_type_before: currentUser.users_type || GUEST_USER_TYPE,
|
|
||||||
users_type_after: user.users_type,
|
|
||||||
users_type_promoted: shouldPromoteUserType,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'update_success',
|
|
||||||
user,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshWechatToken(usersWxOpenid) {
|
|
||||||
const userRecord = await findUserByOpenid(usersWxOpenid)
|
|
||||||
|
|
||||||
if (!userRecord) {
|
|
||||||
throw new AppError('未注册用户', 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = jwtService.signAccessToken({
|
|
||||||
users_id: userRecord.users_id,
|
|
||||||
users_wx_openid: userRecord.users_wx_openid,
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info('微信用户刷新令牌成功', {
|
|
||||||
users_id: userRecord.users_id,
|
|
||||||
users_wx_openid: userRecord.users_wx_openid,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
authenticateWechatUser,
|
|
||||||
updateWechatUserProfile,
|
|
||||||
refreshWechatToken,
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
const axios = require('axios')
|
|
||||||
const env = require('../config/env')
|
|
||||||
const AppError = require('../utils/appError')
|
|
||||||
|
|
||||||
let accessTokenCache = {
|
|
||||||
token: '',
|
|
||||||
expiresAt: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWxOpenId(code) {
|
|
||||||
if (!env.wechatAppId || !env.wechatSecret) {
|
|
||||||
throw new AppError('微信小程序配置缺失', 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = 'https://api.weixin.qq.com/sns/jscode2session'
|
|
||||||
const response = await axios.get(url, {
|
|
||||||
params: {
|
|
||||||
appid: env.wechatAppId,
|
|
||||||
secret: env.wechatSecret,
|
|
||||||
js_code: code,
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
},
|
|
||||||
timeout: 10000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = response.data || {}
|
|
||||||
|
|
||||||
if (data.openid) {
|
|
||||||
return data.openid
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.errcode || data.errmsg) {
|
|
||||||
throw new AppError(`获取OpenID失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
|
|
||||||
wechat: data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AppError('获取OpenID失败: 响应中未包含openid', 502)
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AppError) throw error
|
|
||||||
throw new AppError(`获取微信OpenID时发生错误: ${error.message}`, 502)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWechatAccessToken() {
|
|
||||||
if (accessTokenCache.token && Date.now() < accessTokenCache.expiresAt) {
|
|
||||||
return accessTokenCache.token
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
|
|
||||||
params: {
|
|
||||||
grant_type: 'client_credential',
|
|
||||||
appid: env.wechatAppId,
|
|
||||||
secret: env.wechatSecret,
|
|
||||||
},
|
|
||||||
timeout: 10000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = response.data || {}
|
|
||||||
|
|
||||||
if (!data.access_token) {
|
|
||||||
throw new AppError(`获取微信 access_token 失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
|
|
||||||
wechat: data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
accessTokenCache = {
|
|
||||||
token: data.access_token,
|
|
||||||
expiresAt: Date.now() + Math.max((data.expires_in || 7200) - 300, 60) * 1000,
|
|
||||||
}
|
|
||||||
|
|
||||||
return accessTokenCache.token
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AppError) throw error
|
|
||||||
throw new AppError(`获取微信 access_token 时发生错误: ${error.message}`, 502)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWxPhoneNumber(phoneCode) {
|
|
||||||
const accessToken = await getWechatAccessToken()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(
|
|
||||||
`https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`,
|
|
||||||
{
|
|
||||||
code: phoneCode,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
timeout: 10000,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const data = response.data || {}
|
|
||||||
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber
|
|
||||||
|
|
||||||
if (phone) {
|
|
||||||
return phone
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AppError(`获取微信手机号失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
|
|
||||||
wechat: data,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AppError) throw error
|
|
||||||
throw new AppError(`获取微信手机号时发生错误: ${error.message}`, 502)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getWxOpenId,
|
|
||||||
getWxPhoneNumber,
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
class AppError extends Error {
|
|
||||||
constructor(message, statusCode = 500, details = {}) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'AppError'
|
|
||||||
this.statusCode = statusCode
|
|
||||||
this.details = details
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = AppError
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
module.exports = function asyncHandler(fn) {
|
|
||||||
return function wrappedHandler(req, res, next) {
|
|
||||||
Promise.resolve(fn(req, res, next)).catch(next)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
|
|
||||||
function getBuildInfoFilePath() {
|
|
||||||
return path.resolve(__dirname, '..', '..', 'build-info.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBuildTimestamp() {
|
|
||||||
if (process.env.BUILD_TIME) {
|
|
||||||
return process.env.BUILD_TIME
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = getBuildInfoFilePath()
|
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf8')
|
|
||||||
const payload = JSON.parse(content)
|
|
||||||
return payload.buildTime || null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getBuildTimestamp,
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
function sanitizeString(value) {
|
|
||||||
if (typeof value !== 'string') return ''
|
|
||||||
return value.replace(/[<>\\]/g, '').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizePayload(payload = {}) {
|
|
||||||
return Object.keys(payload).reduce((acc, key) => {
|
|
||||||
const value = payload[key]
|
|
||||||
acc[key] = typeof value === 'string' ? sanitizeString(value) : value
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
sanitizeString,
|
|
||||||
sanitizePayload,
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
const test = require('node:test')
|
const test = require('node:test')
|
||||||
const assert = require('node:assert/strict')
|
const assert = require('node:assert/strict')
|
||||||
const request = require('supertest')
|
const request = require('supertest')
|
||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
|
|
||||||
const env = require('../../src/config/env')
|
|
||||||
const { createApp } = require('../../src/index')
|
const { createApp } = require('../../src/index')
|
||||||
const userService = require('../../src/services/userService')
|
|
||||||
|
|
||||||
const buildInfoPath = path.resolve(__dirname, '..', '..', 'build-info.json')
|
|
||||||
|
|
||||||
test('POST /api/health 仍返回统一结构', async () => {
|
test('POST /api/health 仍返回统一结构', async () => {
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
@@ -20,47 +13,6 @@ test('POST /api/health 仍返回统一结构', async () => {
|
|||||||
assert.equal(response.body.data.status, 'healthy')
|
assert.equal(response.body.data.status, 'healthy')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/test-helloworld 返回统一结构和构建时间', async (t) => {
|
|
||||||
const app = createApp()
|
|
||||||
const buildTime = '2026-03-20T08:00:00.000Z'
|
|
||||||
|
|
||||||
fs.writeFileSync(buildInfoPath, JSON.stringify({ buildTime }, null, 2))
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
if (fs.existsSync(buildInfoPath)) {
|
|
||||||
fs.rmSync(buildInfoPath)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await request(app).post('/api/test-helloworld').send({})
|
|
||||||
|
|
||||||
assert.equal(response.status, 200)
|
|
||||||
assert.equal(response.body.code, 200)
|
|
||||||
assert.equal(response.body.msg, '请求成功')
|
|
||||||
assert.equal(response.body.data.message, 'Hello, World!')
|
|
||||||
assert.equal(response.body.data.build_time, buildTime)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /api/test-helloworld 返回 404', async () => {
|
|
||||||
const app = createApp()
|
|
||||||
const response = await request(app).get('/api/test-helloworld')
|
|
||||||
|
|
||||||
assert.equal(response.status, 404)
|
|
||||||
assert.equal(response.body.code, 404)
|
|
||||||
assert.equal(response.body.msg, 'Route not found')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/test-helloworld 返回统一结构', async () => {
|
|
||||||
const app = createApp()
|
|
||||||
const response = await request(app).post('/api/test-helloworld').send({})
|
|
||||||
|
|
||||||
assert.equal(response.status, 200)
|
|
||||||
assert.equal(response.body.code, 200)
|
|
||||||
assert.equal(response.body.msg, '请求成功')
|
|
||||||
assert.equal(response.body.data.message, 'Hello, World!')
|
|
||||||
assert.ok(Object.prototype.hasOwnProperty.call(response.body.data, 'build_time'))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('未匹配路由返回统一 404', async () => {
|
test('未匹配路由返回统一 404', async () => {
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
const response = await request(app).get('/not-found-route')
|
const response = await request(app).get('/not-found-route')
|
||||||
@@ -70,154 +22,3 @@ test('未匹配路由返回统一 404', async () => {
|
|||||||
assert.equal(response.body.msg, 'Route not found')
|
assert.equal(response.body.msg, 'Route not found')
|
||||||
assert.equal(response.body.data.path, '/not-found-route')
|
assert.equal(response.body.data.path, '/not-found-route')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/wechat/login 未注册用户返回 404', async (t) => {
|
|
||||||
const origin = userService.authenticateWechatUser
|
|
||||||
userService.authenticateWechatUser = async () => {
|
|
||||||
const error = new Error('未注册用户')
|
|
||||||
error.statusCode = 404
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
userService.authenticateWechatUser = origin
|
|
||||||
})
|
|
||||||
|
|
||||||
const app = createApp()
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/api/wechat/login')
|
|
||||||
.send({ users_wx_code: 'mock-code-success' })
|
|
||||||
|
|
||||||
assert.equal(response.status, 404)
|
|
||||||
assert.equal(response.body.code, 404)
|
|
||||||
assert.equal(response.body.msg, '未注册用户')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/wechat/login 非 JSON 请求体返回 415', async () => {
|
|
||||||
const app = createApp()
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/api/wechat/login')
|
|
||||||
.set('Content-Type', 'application/x-www-form-urlencoded')
|
|
||||||
.send('users_wx_code=mock-code')
|
|
||||||
|
|
||||||
assert.equal(response.status, 415)
|
|
||||||
assert.equal(response.body.code, 415)
|
|
||||||
assert.equal(response.body.msg, '请求体必须为 application/json')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/wechat/login 成功时返回 is_info_complete', async (t) => {
|
|
||||||
const origin = userService.authenticateWechatUser
|
|
||||||
userService.authenticateWechatUser = async () => ({
|
|
||||||
is_info_complete: true,
|
|
||||||
status: 'login_success',
|
|
||||||
token: 'mock-token',
|
|
||||||
user: {
|
|
||||||
users_id: 'U1003',
|
|
||||||
users_type: '注册用户',
|
|
||||||
users_name: '李四',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
userService.authenticateWechatUser = origin
|
|
||||||
})
|
|
||||||
|
|
||||||
const app = createApp()
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/api/wechat/login')
|
|
||||||
.send({ users_wx_code: 'mock-code' })
|
|
||||||
|
|
||||||
assert.equal(response.status, 200)
|
|
||||||
assert.equal(response.body.code, 200)
|
|
||||||
assert.equal(response.body.msg, '登录成功')
|
|
||||||
assert.equal(response.body.data.is_info_complete, true)
|
|
||||||
assert.equal(response.body.data.user.users_type, '注册用户')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/wechat/profile 更新成功后返回脱敏手机号', async (t) => {
|
|
||||||
const origin = userService.updateWechatUserProfile
|
|
||||||
userService.updateWechatUserProfile = async () => ({
|
|
||||||
status: 'update_success',
|
|
||||||
user: {
|
|
||||||
users_id: 'U1002',
|
|
||||||
users_type: '注册用户',
|
|
||||||
users_name: '张三',
|
|
||||||
users_phone: '13800138000',
|
|
||||||
users_phone_masked: '138****8000',
|
|
||||||
users_picture: 'https://example.com/a.png',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
userService.updateWechatUserProfile = origin
|
|
||||||
})
|
|
||||||
|
|
||||||
const token = require('jsonwebtoken').sign({ users_id: 'U1002', users_wx_openid: 'openid-1' }, env.jwtSecret, {
|
|
||||||
expiresIn: env.jwtExpiresIn,
|
|
||||||
})
|
|
||||||
|
|
||||||
const app = createApp()
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/api/wechat/profile')
|
|
||||||
.set('users_wx_openid', 'openid-1')
|
|
||||||
.set('Authorization', `Bearer ${token}`)
|
|
||||||
.send({
|
|
||||||
users_name: '张三',
|
|
||||||
users_phone_code: 'phone-code',
|
|
||||||
users_picture: 'https://example.com/a.png',
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.equal(response.status, 200)
|
|
||||||
assert.equal(response.body.code, 200)
|
|
||||||
assert.equal(response.body.msg, '信息更新成功')
|
|
||||||
assert.equal(response.body.data.user.users_phone_masked, '138****8000')
|
|
||||||
assert.equal(response.body.data.user.users_type, '注册用户')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/wechat/profile 缺少 token 返回 401', async () => {
|
|
||||||
const app = createApp()
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/api/wechat/profile')
|
|
||||||
.set('users_wx_openid', 'openid-1')
|
|
||||||
.send({
|
|
||||||
users_name: '张三',
|
|
||||||
users_phone_code: 'phone-code',
|
|
||||||
users_picture: 'https://example.com/a.png',
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.equal(response.status, 401)
|
|
||||||
assert.equal(response.body.code, 401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/wechat/refresh-token 返回 token', async (t) => {
|
|
||||||
const origin = userService.refreshWechatToken
|
|
||||||
userService.refreshWechatToken = async () => ({
|
|
||||||
token: 'new-token',
|
|
||||||
})
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
userService.refreshWechatToken = origin
|
|
||||||
})
|
|
||||||
|
|
||||||
const app = createApp()
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/api/wechat/refresh-token')
|
|
||||||
.set('users_wx_openid', 'openid-1')
|
|
||||||
.send({})
|
|
||||||
|
|
||||||
assert.equal(response.status, 200)
|
|
||||||
assert.equal(response.body.code, 200)
|
|
||||||
assert.equal(response.body.msg, '刷新成功')
|
|
||||||
assert.equal(response.body.data.token, 'new-token')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /api/wechat/refresh-token 缺少 users_wx_openid 返回 401', async () => {
|
|
||||||
const app = createApp()
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/api/wechat/refresh-token')
|
|
||||||
.send({})
|
|
||||||
|
|
||||||
assert.equal(response.status, 401)
|
|
||||||
assert.equal(response.body.code, 401)
|
|
||||||
assert.equal(response.body.msg, '请求头缺少 users_wx_openid')
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
const test = require('node:test')
|
|
||||||
const assert = require('node:assert/strict')
|
|
||||||
|
|
||||||
const wechatService = require('../src/services/wechatService')
|
|
||||||
const pocketbaseService = require('../src/services/pocketbaseService')
|
|
||||||
const jwtService = require('../src/services/jwtService')
|
|
||||||
const userService = require('../src/services/userService')
|
|
||||||
|
|
||||||
test('authenticateWechatUser 首次注册默认写入游客类型', async (t) => {
|
|
||||||
const originGetWxOpenId = wechatService.getWxOpenId
|
|
||||||
const originListUsersByFilter = pocketbaseService.listUsersByFilter
|
|
||||||
const originCreateUser = pocketbaseService.createUser
|
|
||||||
const originSignAccessToken = jwtService.signAccessToken
|
|
||||||
const originGetCompanyByCompanyId = pocketbaseService.getCompanyByCompanyId
|
|
||||||
|
|
||||||
wechatService.getWxOpenId = async () => 'openid-register'
|
|
||||||
pocketbaseService.listUsersByFilter = async () => []
|
|
||||||
pocketbaseService.createUser = async (payload) => ({
|
|
||||||
id: 'pb-1',
|
|
||||||
created: '2026-03-19T00:00:00Z',
|
|
||||||
updated: '2026-03-19T00:00:00Z',
|
|
||||||
...payload,
|
|
||||||
})
|
|
||||||
pocketbaseService.getCompanyByCompanyId = async () => null
|
|
||||||
jwtService.signAccessToken = () => 'token-1'
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
wechatService.getWxOpenId = originGetWxOpenId
|
|
||||||
pocketbaseService.listUsersByFilter = originListUsersByFilter
|
|
||||||
pocketbaseService.createUser = originCreateUser
|
|
||||||
pocketbaseService.getCompanyByCompanyId = originGetCompanyByCompanyId
|
|
||||||
jwtService.signAccessToken = originSignAccessToken
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await userService.authenticateWechatUser({
|
|
||||||
users_wx_code: 'code-1',
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.equal(result.status, 'register_success')
|
|
||||||
assert.equal(result.is_info_complete, false)
|
|
||||||
assert.equal(result.user.users_type, '游客')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('updateWechatUserProfile 首次完整补充资料时升级为注册用户', async (t) => {
|
|
||||||
const originGetWxOpenId = wechatService.getWxOpenId
|
|
||||||
const originGetWxPhoneNumber = wechatService.getWxPhoneNumber
|
|
||||||
const originListUsersByFilter = pocketbaseService.listUsersByFilter
|
|
||||||
const originUpdateUser = pocketbaseService.updateUser
|
|
||||||
const originGetCompanyByCompanyId = pocketbaseService.getCompanyByCompanyId
|
|
||||||
|
|
||||||
wechatService.getWxOpenId = async () => 'openid-profile'
|
|
||||||
wechatService.getWxPhoneNumber = async () => '13800138000'
|
|
||||||
pocketbaseService.listUsersByFilter = async (filter) => {
|
|
||||||
if (filter.includes('users_wx_openid')) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'pb-2',
|
|
||||||
users_id: 'U2001',
|
|
||||||
users_type: '游客',
|
|
||||||
users_name: '',
|
|
||||||
users_phone: '',
|
|
||||||
users_picture: '',
|
|
||||||
users_wx_openid: 'openid-profile',
|
|
||||||
created: '2026-03-19T00:00:00Z',
|
|
||||||
updated: '2026-03-19T00:00:00Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
pocketbaseService.updateUser = async (_id, payload) => ({
|
|
||||||
id: 'pb-2',
|
|
||||||
users_id: 'U2001',
|
|
||||||
users_wx_openid: 'openid-profile',
|
|
||||||
users_type: payload.users_type || '游客',
|
|
||||||
users_name: payload.users_name,
|
|
||||||
users_phone: payload.users_phone,
|
|
||||||
users_picture: payload.users_picture,
|
|
||||||
created: '2026-03-19T00:00:00Z',
|
|
||||||
updated: '2026-03-19T00:10:00Z',
|
|
||||||
})
|
|
||||||
pocketbaseService.getCompanyByCompanyId = async () => null
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
wechatService.getWxOpenId = originGetWxOpenId
|
|
||||||
wechatService.getWxPhoneNumber = originGetWxPhoneNumber
|
|
||||||
pocketbaseService.listUsersByFilter = originListUsersByFilter
|
|
||||||
pocketbaseService.updateUser = originUpdateUser
|
|
||||||
pocketbaseService.getCompanyByCompanyId = originGetCompanyByCompanyId
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await userService.updateWechatUserProfile('openid-profile', {
|
|
||||||
users_name: '张三',
|
|
||||||
users_phone_code: 'phone-code-1',
|
|
||||||
users_picture: 'https://example.com/a.png',
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.equal(result.status, 'update_success')
|
|
||||||
assert.equal(result.user.users_type, '注册用户')
|
|
||||||
assert.equal(result.user.users_phone_masked, '138****8000')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('updateWechatUserProfile 非首次补充资料时不覆盖已确定类型', async (t) => {
|
|
||||||
const originGetWxOpenId = wechatService.getWxOpenId
|
|
||||||
const originGetWxPhoneNumber = wechatService.getWxPhoneNumber
|
|
||||||
const originListUsersByFilter = pocketbaseService.listUsersByFilter
|
|
||||||
const originUpdateUser = pocketbaseService.updateUser
|
|
||||||
const originGetCompanyByCompanyId = pocketbaseService.getCompanyByCompanyId
|
|
||||||
|
|
||||||
wechatService.getWxOpenId = async () => 'openid-registered'
|
|
||||||
wechatService.getWxPhoneNumber = async () => '13900139000'
|
|
||||||
pocketbaseService.listUsersByFilter = async (filter) => {
|
|
||||||
if (filter.includes('users_wx_openid')) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'pb-3',
|
|
||||||
users_id: 'U2002',
|
|
||||||
users_type: '注册用户',
|
|
||||||
users_name: '老用户',
|
|
||||||
users_phone: '13800138000',
|
|
||||||
users_picture: 'https://example.com/old.png',
|
|
||||||
users_wx_openid: 'openid-registered',
|
|
||||||
created: '2026-03-19T00:00:00Z',
|
|
||||||
updated: '2026-03-19T00:00:00Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
pocketbaseService.updateUser = async (_id, payload) => ({
|
|
||||||
id: 'pb-3',
|
|
||||||
users_id: 'U2002',
|
|
||||||
users_wx_openid: 'openid-registered',
|
|
||||||
users_type: '注册用户',
|
|
||||||
users_name: payload.users_name,
|
|
||||||
users_phone: payload.users_phone,
|
|
||||||
users_picture: payload.users_picture,
|
|
||||||
created: '2026-03-19T00:00:00Z',
|
|
||||||
updated: '2026-03-19T00:10:00Z',
|
|
||||||
})
|
|
||||||
pocketbaseService.getCompanyByCompanyId = async () => null
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
wechatService.getWxOpenId = originGetWxOpenId
|
|
||||||
wechatService.getWxPhoneNumber = originGetWxPhoneNumber
|
|
||||||
pocketbaseService.listUsersByFilter = originListUsersByFilter
|
|
||||||
pocketbaseService.updateUser = originUpdateUser
|
|
||||||
pocketbaseService.getCompanyByCompanyId = originGetCompanyByCompanyId
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await userService.updateWechatUserProfile('openid-registered', {
|
|
||||||
users_name: '新名字',
|
|
||||||
users_phone_code: 'phone-code-2',
|
|
||||||
users_picture: 'https://example.com/new.png',
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.equal(result.user.users_type, '注册用户')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('refreshWechatToken 返回新的 token', async (t) => {
|
|
||||||
const originListUsersByFilter = pocketbaseService.listUsersByFilter
|
|
||||||
const originSignAccessToken = jwtService.signAccessToken
|
|
||||||
|
|
||||||
pocketbaseService.listUsersByFilter = async () => [
|
|
||||||
{
|
|
||||||
id: 'pb-4',
|
|
||||||
users_id: 'U3001',
|
|
||||||
users_wx_openid: 'openid-refresh',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
jwtService.signAccessToken = () => 'refresh-token'
|
|
||||||
|
|
||||||
t.after(() => {
|
|
||||||
pocketbaseService.listUsersByFilter = originListUsersByFilter
|
|
||||||
jwtService.signAccessToken = originSignAccessToken
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await userService.refreshWechatToken('openid-refresh')
|
|
||||||
|
|
||||||
assert.equal(result.token, 'refresh-token')
|
|
||||||
})
|
|
||||||
114
docs/ARCHIVE.md
114
docs/ARCHIVE.md
@@ -2,6 +2,120 @@
|
|||||||
|
|
||||||
## 归档日期
|
## 归档日期
|
||||||
|
|
||||||
|
- 2026-03-23
|
||||||
|
|
||||||
|
## 归档范围
|
||||||
|
|
||||||
|
本次归档覆盖 PocketBase hooks 项目在微信登录注册、PocketBase 原生 token、openid 身份收敛、错误观测、索引修复与 auth 兼容字段方面的修复与规范同步,涉及:
|
||||||
|
|
||||||
|
- `pocket-base/` 作为正式 hooks 项目继续收敛规范
|
||||||
|
- 微信登录链路错误显式返回
|
||||||
|
- `recordAuthResponse` 使用空 `authMethod`
|
||||||
|
- 登录/资料更新阶段 auth 保存失败信息透传
|
||||||
|
- 移除 hooks 查询中的 `-created` 排序,修复 `invalid sort field "created"`
|
||||||
|
- `users_phone` 唯一索引改普通索引,允许空手机号用户注册
|
||||||
|
- `tbl_auth_users` 继续以 `openid` 作为业务身份锚点
|
||||||
|
- 为 PocketBase `auth` 集合兼容写入占位 `email`、随机密码与 `passwordConfirm`
|
||||||
|
- OpenSpec 变更文档补录到 `pocket-base/spec/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、接口与认证结果
|
||||||
|
|
||||||
|
### 当前 active hooks 接口
|
||||||
|
|
||||||
|
- `POST /api/system/test-helloworld`
|
||||||
|
- `POST /api/system/health`
|
||||||
|
- `POST /api/wechat/login`
|
||||||
|
- `POST /api/wechat/profile`
|
||||||
|
- `POST /api/wechat/refresh-token`
|
||||||
|
|
||||||
|
### 当前认证规则
|
||||||
|
|
||||||
|
- 正式鉴权仅使用 `Authorization: Bearer <token>`
|
||||||
|
- `Open-Authorization` 不属于接口契约
|
||||||
|
- `users_wx_openid` Header 已移除
|
||||||
|
- 业务身份由当前 auth record 的 `openid` 唯一确定
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、数据模型与落库策略
|
||||||
|
|
||||||
|
### 1. `tbl_auth_users`
|
||||||
|
|
||||||
|
- 维持 PocketBase `auth` 集合
|
||||||
|
- 业务身份锚点为 `openid`
|
||||||
|
- 目标规则:除 `openid` 外,自定义业务字段均允许为空
|
||||||
|
|
||||||
|
### 2. auth 集合兼容字段
|
||||||
|
|
||||||
|
由于 `tbl_auth_users` 是 auth 集合,为满足 PocketBase 原生 auth 保存要求,登录创建阶段补充:
|
||||||
|
|
||||||
|
- `email = <openid>@wechat.local`
|
||||||
|
- 随机密码
|
||||||
|
- `passwordConfirm`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 上述 `email` 为占位认证标识,不代表真实邮箱。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、问题修复归档
|
||||||
|
|
||||||
|
### 1. 通用 400 `Something went wrong while processing your request.`
|
||||||
|
|
||||||
|
处理方式:
|
||||||
|
|
||||||
|
- 登录路由本地 try/catch 显式返回 `{ code, msg, data }`
|
||||||
|
- 全局错误包装提前注册
|
||||||
|
- 保存 auth 用户时透传原始错误信息
|
||||||
|
|
||||||
|
### 2. `invalid sort field "created"`
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- hooks 内多个精确查询误用了 `-created` 排序
|
||||||
|
|
||||||
|
处理方式:
|
||||||
|
|
||||||
|
- 统一移除 active hooks 中所有 `-created` 排序
|
||||||
|
|
||||||
|
### 3. 注册成功前数据库无新记录
|
||||||
|
|
||||||
|
已识别的高风险点:
|
||||||
|
|
||||||
|
- `users_phone` 唯一索引会让空手机号重复冲突
|
||||||
|
|
||||||
|
处理方式:
|
||||||
|
|
||||||
|
- 改为普通索引
|
||||||
|
- 手机号唯一性改由资料完善阶段业务校验负责
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、规范同步位置
|
||||||
|
|
||||||
|
本次 OpenSpec 记录新增:
|
||||||
|
|
||||||
|
- `pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md`
|
||||||
|
|
||||||
|
当前 active 契约文件:
|
||||||
|
|
||||||
|
- `pocket-base/spec/openapi.yaml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、当前边界
|
||||||
|
|
||||||
|
1. `tbl_auth_users` 作为 PocketBase `auth` 集合,仍受 PocketBase 内置 auth 规则影响。
|
||||||
|
2. schema 脚本放宽自定义字段必填约束时,PocketBase 服务端更新 auth 集合可能返回通用 500,需要结合服务端日志进一步确认。
|
||||||
|
3. 若线上仍报旧错误,通常表示最新 hooks 或 schema 尚未部署生效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 归档日期
|
||||||
|
|
||||||
- 2026-03-20
|
- 2026-03-20
|
||||||
|
|
||||||
## 归档范围
|
## 归档范围
|
||||||
|
|||||||
17
docs/api.md
17
docs/api.md
@@ -104,11 +104,11 @@
|
|||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `users_wx_code` | string | 是 | 微信小程序登录临时凭证 code,用于换取 `users_wx_openid` |
|
| `users_wx_code` | string | 是 | 微信小程序登录临时凭证 code,用于换取 `openid` |
|
||||||
|
|
||||||
### 处理逻辑
|
### 处理逻辑
|
||||||
|
|
||||||
- 使用 `users_wx_code` 向微信服务端换取 `users_wx_openid`
|
- 使用 `users_wx_code` 向微信服务端换取 `openid`
|
||||||
- 如果数据库中不存在该用户,则自动创建新账号:
|
- 如果数据库中不存在该用户,则自动创建新账号:
|
||||||
- 初始化 `users_type = 游客`
|
- 初始化 `users_type = 游客`
|
||||||
- 如果数据库中已存在该用户,则直接登录
|
- 如果数据库中已存在该用户,则直接登录
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
"users_phone": "13800138000",
|
"users_phone": "13800138000",
|
||||||
"users_phone_masked": "138****8000",
|
"users_phone_masked": "138****8000",
|
||||||
"users_picture": "https://example.com/avatar.png",
|
"users_picture": "https://example.com/avatar.png",
|
||||||
"users_wx_openid": "oAbCdEfGh123456789",
|
"openid": "oAbCdEfGh123456789",
|
||||||
"company_id": "C10001",
|
"company_id": "C10001",
|
||||||
"company": null,
|
"company": null,
|
||||||
"pb_id": "abc123xyz",
|
"pb_id": "abc123xyz",
|
||||||
@@ -154,7 +154,6 @@
|
|||||||
- **请求方式**:`POST`
|
- **请求方式**:`POST`
|
||||||
- **请求头**:
|
- **请求头**:
|
||||||
- `Content-Type: application/json`
|
- `Content-Type: application/json`
|
||||||
- `users_wx_openid: 微信用户唯一标识`
|
|
||||||
- `Authorization: Bearer <token>`
|
- `Authorization: Bearer <token>`
|
||||||
|
|
||||||
### 请求参数
|
### 请求参数
|
||||||
@@ -177,7 +176,7 @@
|
|||||||
|
|
||||||
### 处理逻辑
|
### 处理逻辑
|
||||||
|
|
||||||
- 从请求头 `users_wx_openid` 读取当前用户身份
|
- 从 `Authorization` 对应的 PocketBase auth record 读取当前用户 `openid`
|
||||||
- 校验 `Authorization`
|
- 校验 `Authorization`
|
||||||
- 不再从 body 读取 `users_wx_code`
|
- 不再从 body 读取 `users_wx_code`
|
||||||
- 使用 `users_phone_code` 调微信官方接口换取真实手机号
|
- 使用 `users_phone_code` 调微信官方接口换取真实手机号
|
||||||
@@ -200,7 +199,7 @@
|
|||||||
"users_phone": "13800138000",
|
"users_phone": "13800138000",
|
||||||
"users_phone_masked": "138****8000",
|
"users_phone_masked": "138****8000",
|
||||||
"users_picture": "https://example.com/avatar.png",
|
"users_picture": "https://example.com/avatar.png",
|
||||||
"users_wx_openid": "oAbCdEfGh123456789",
|
"openid": "oAbCdEfGh123456789",
|
||||||
"company_id": "",
|
"company_id": "",
|
||||||
"company": null,
|
"company": null,
|
||||||
"pb_id": "abc123xyz",
|
"pb_id": "abc123xyz",
|
||||||
@@ -218,7 +217,7 @@
|
|||||||
- **接口地址**:`/wechat/refresh-token`
|
- **接口地址**:`/wechat/refresh-token`
|
||||||
- **请求方式**:`POST`
|
- **请求方式**:`POST`
|
||||||
- **请求头**:
|
- **请求头**:
|
||||||
- `users_wx_openid: 微信用户唯一标识`
|
- `Authorization: Bearer <token>`
|
||||||
|
|
||||||
> 说明:本接口**不要求旧 `Authorization`**。
|
> 说明:本接口**不要求旧 `Authorization`**。
|
||||||
|
|
||||||
@@ -228,7 +227,7 @@
|
|||||||
|
|
||||||
### 处理逻辑
|
### 处理逻辑
|
||||||
|
|
||||||
- 仅通过请求头中的 `users_wx_openid` 定位用户
|
- 仅通过当前 `Authorization` 对应认证用户定位身份
|
||||||
- 若用户存在,则签发新的 JWT token
|
- 若用户存在,则签发新的 JWT token
|
||||||
- 若用户不存在,则返回 `404`
|
- 若用户不存在,则返回 `404`
|
||||||
|
|
||||||
@@ -279,7 +278,7 @@ Authorization: Bearer <token>
|
|||||||
### 3. `refresh-token` 接口当前只需要:
|
### 3. `refresh-token` 接口当前只需要:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
users_wx_openid: <openid>
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
不需要旧 `Authorization`。
|
不需要旧 `Authorization`。
|
||||||
|
|||||||
95
docs/newpb.md
Normal file
95
docs/newpb.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
针对你的复杂权限需求,建议采用 **“RBAC(基于角色的访问控制)+ ABAC(基于属性的访问控制)+ 个性化覆盖(Overrides)”** 的混合模式。这种结构能支持从“大颗粒度角色”到“极细颗粒度字段/行”的动态权限矩阵。
|
||||||
|
|
||||||
|
以下是为你设计的实施方案及关键表结构规划:
|
||||||
|
|
||||||
|
### 一、 核心表方案设计
|
||||||
|
|
||||||
|
为了实现“字段级”和“行级”的动态控制,我们需要将 **资源(Resource)**、**权限定义(Permission)**、**角色(Role)** 与 **用户(User)** 彻底解耦。
|
||||||
|
|
||||||
|
#### 1. 用户基础表:`tbl_auth_users`
|
||||||
|
作为全局身份锚点,记录用户的静态信息和动态属性(部门、等级)。
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **user_id** | BigInt (PK) | 内部全局唯一 ID |
|
||||||
|
| **openid** | String (Unique) | **全局身份锚点**,微信唯一标识 |
|
||||||
|
| **user_name** | String | 姓名/昵称 |
|
||||||
|
| **org_id** | Int | 所属组织/部门 ID(影响行级权限的关键属性) |
|
||||||
|
| **rank_level** | Int | 职级/等级(影响动态权限矩阵的关键属性) |
|
||||||
|
| **status** | Int | 账户状态 (1: 正常, 0: 禁用) |
|
||||||
|
| **user_type** | Int | 账户类型 (0: 微信小程序,1: 管理平台,2: 其他) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. 资源定义表:`tbl_auth_resources`
|
||||||
|
定义系统中哪些表、哪些字段属于受控资源。
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **res_id** | Int (PK) | 资源 ID |
|
||||||
|
| **table_name** | String | 数据库表名 |
|
||||||
|
| **column_name** | String | 字段名(如果是表级权限,此项可为空或设为 '*') |
|
||||||
|
| **res_type** | Enum | 资源类型:`TABLE`(行/全表), `COLUMN`(字段级) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. 角色与基础权限:`tbl_auth_roles` & `tbl_auth_role_perms`
|
||||||
|
实现通用的权限模板,方便批量管理。
|
||||||
|
|
||||||
|
* **`tbl_auth_roles`**: 角色表(如:财务经理、普通销售)。
|
||||||
|
* **`tbl_auth_role_perms`**: 角色权限关联表,定义该角色对某个资源的操作(读/写/无)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. **核心:个性化权限覆盖表 `tbl_auth_user_overrides`**
|
||||||
|
这是满足你“某个用户对某个字段单独设置”需求的关键。
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **id** | BigInt (PK) | 自增 ID |
|
||||||
|
| **user_id** | BigInt | 用户 ID(关联 `tbl_auth_users`) |
|
||||||
|
| **res_id** | Int | 资源 ID(关联 `tbl_auth_resources`) |
|
||||||
|
| **access_level** | Int | 权限值 (0: 无权, 1: 只读, 2: 读写) |
|
||||||
|
| **priority** | Int | 优先级(当角色权限与个人设置冲突时,以此为准) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. **核心:行级过滤策略表 `tbl_auth_row_scopes`**
|
||||||
|
实现“某个用户只能看自己部门/某几个项目”的动态逻辑。
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **id** | Int (PK) | 策略 ID |
|
||||||
|
| **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}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 二、 权限实施方案逻辑
|
||||||
|
|
||||||
|
你的权限矩阵页面将由以下逻辑驱动:
|
||||||
|
|
||||||
|
#### 1. 权限计算路径 (Effective Permissions)
|
||||||
|
当用户访问某个数据时,系统按照以下顺序合并权限:
|
||||||
|
1. **取基础属性**:获取用户的 `org_id` 和 `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`)引用的是动态变量,**权限会自动生效**,无需重新授权。
|
||||||
|
* **缓存策略**:建议将计算后的“最终权限清单”缓存到 Redis。当用户在后台矩阵页面修改权限,或者发生组织架构调整时,通过 `wx_openid` **主动失效(Purge)** 该用户的 Redis 缓存。
|
||||||
|
|
||||||
|
#### 3. 字段级权限实现 (Field-Level)
|
||||||
|
在接口层(中间件),根据计算出的字段级权限清单(即用户对该 Table 下哪些 Column 有读权),动态过滤返回的 JSON 结构。
|
||||||
|
|
||||||
|
### 三、 总结:你需要几张表?
|
||||||
|
|
||||||
|
为了实现你描述的系统,最精简需要 **5 张表**:
|
||||||
|
1. **`tbl_auth_users`**:用户主体(含 OpenID、部门、等级)。
|
||||||
|
2. **`tbl_auth_resources`**:资源清单(表名、字段名)。
|
||||||
|
3. **`tbl_auth_roles`**:角色定义。
|
||||||
|
4. **`tbl_auth_role_perms`** / **`tbl_auth_user_overrides`**:权限映射(解决字段级和个人特权)。
|
||||||
|
5. **`tbl_auth_row_scopes`**:行级过滤表达式(解决多维数据隔离)。
|
||||||
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: 用户不存在
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "pocketbase.js",
|
"main": "pocketbase.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"init:newpb": "node pocketbase.newpb.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
261
script/pocketbase.newpb.js
Normal file
261
script/pocketbase.newpb.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
function readEnvFile(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return {};
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
const index = line.indexOf('=');
|
||||||
|
if (index === -1) continue;
|
||||||
|
const key = line.slice(0, index).trim();
|
||||||
|
const value = line.slice(index + 1).trim();
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendEnv = readEnvFile(path.resolve(__dirname, '..', 'back-end', '.env'));
|
||||||
|
const PB_URL = (process.env.PB_URL || backendEnv.POCKETBASE_API_URL || 'http://127.0.0.1:8090').replace(/\/+$/, '');
|
||||||
|
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL || backendEnv.PB_ADMIN_EMAIL || '';
|
||||||
|
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD || backendEnv.PB_ADMIN_PASSWORD || '';
|
||||||
|
const AUTH_TOKEN = process.env.POCKETBASE_AUTH_TOKEN || backendEnv.POCKETBASE_AUTH_TOKEN || '';
|
||||||
|
|
||||||
|
const pb = new PocketBase(PB_URL);
|
||||||
|
|
||||||
|
const collections = [
|
||||||
|
{
|
||||||
|
name: 'tbl_auth_users',
|
||||||
|
type: 'auth',
|
||||||
|
fields: [
|
||||||
|
{ name: 'user_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_id', type: 'text' },
|
||||||
|
{ name: 'users_name', type: 'text' },
|
||||||
|
{ name: 'users_idtype', type: 'text' },
|
||||||
|
{ name: 'users_id_number', type: 'text' },
|
||||||
|
{ name: 'users_phone', type: 'text' },
|
||||||
|
{ name: 'users_level', type: 'text' },
|
||||||
|
{ name: 'users_type', type: 'text' },
|
||||||
|
{ name: 'users_status', type: 'text' },
|
||||||
|
{ name: 'company_id', type: 'text' },
|
||||||
|
{ name: 'users_parent_id', type: 'text' },
|
||||||
|
{ name: 'users_promo_code', type: 'text' },
|
||||||
|
{ name: 'users_id_pic_a', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||||
|
{ name: 'users_id_pic_b', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||||
|
{ name: 'users_title_picture', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||||
|
{ name: 'users_picture', type: 'text' },
|
||||||
|
{ 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_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 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)',
|
||||||
|
'CREATE INDEX idx_tbl_auth_users_usergroups_id ON tbl_auth_users (usergroups_id)',
|
||||||
|
'CREATE INDEX idx_tbl_auth_users_users_parent_id ON tbl_auth_users (users_parent_id)'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tbl_auth_resources',
|
||||||
|
type: 'base',
|
||||||
|
fields: [
|
||||||
|
{ name: 'res_id', type: 'text', required: true },
|
||||||
|
{ name: 'table_name', type: 'text', required: true },
|
||||||
|
{ name: 'column_name', type: 'text' },
|
||||||
|
{ name: 'res_type', type: 'text', required: true }
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
'CREATE UNIQUE INDEX idx_tbl_auth_resources_res_id ON tbl_auth_resources (res_id)',
|
||||||
|
'CREATE INDEX idx_tbl_auth_resources_table_name ON tbl_auth_resources (table_name)',
|
||||||
|
'CREATE INDEX idx_tbl_auth_resources_res_type ON tbl_auth_resources (res_type)',
|
||||||
|
'CREATE UNIQUE INDEX idx_tbl_auth_resources_unique_res ON tbl_auth_resources (table_name, column_name, res_type)'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tbl_auth_roles',
|
||||||
|
type: 'base',
|
||||||
|
fields: [
|
||||||
|
{ name: 'role_id', type: 'text', required: true },
|
||||||
|
{ name: 'role_name', type: 'text', required: true },
|
||||||
|
{ name: 'role_code', type: 'text' },
|
||||||
|
{ name: 'role_status', type: 'number' },
|
||||||
|
{ name: 'role_remark', type: 'text' }
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
'CREATE UNIQUE INDEX idx_tbl_auth_roles_role_id ON tbl_auth_roles (role_id)',
|
||||||
|
'CREATE UNIQUE INDEX idx_tbl_auth_roles_role_name ON tbl_auth_roles (role_name)',
|
||||||
|
'CREATE UNIQUE INDEX idx_tbl_auth_roles_role_code ON tbl_auth_roles (role_code)'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tbl_auth_role_perms',
|
||||||
|
type: 'base',
|
||||||
|
fields: [
|
||||||
|
{ name: 'role_perm_id', type: 'text', required: true },
|
||||||
|
{ name: 'role_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_role_perms_role_perm_id ON tbl_auth_role_perms (role_perm_id)',
|
||||||
|
'CREATE INDEX idx_tbl_auth_role_perms_role_id ON tbl_auth_role_perms (role_id)',
|
||||||
|
'CREATE INDEX idx_tbl_auth_role_perms_res_id ON tbl_auth_role_perms (res_id)',
|
||||||
|
'CREATE UNIQUE INDEX idx_tbl_auth_role_perms_unique_map ON tbl_auth_role_perms (role_id, res_id)'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tbl_auth_user_overrides',
|
||||||
|
type: 'base',
|
||||||
|
fields: [
|
||||||
|
{ name: 'override_id', type: 'text', required: true },
|
||||||
|
{ name: 'user_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_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)'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tbl_auth_row_scopes',
|
||||||
|
type: 'base',
|
||||||
|
fields: [
|
||||||
|
{ name: 'scope_id', type: 'text', required: true },
|
||||||
|
{ name: 'target_type', type: 'text', required: true },
|
||||||
|
{ name: 'target_id', type: 'text', required: true },
|
||||||
|
{ name: 'table_name', type: 'text', required: true },
|
||||||
|
{ name: 'filter_sql', type: 'editor', required: true }
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
'CREATE UNIQUE INDEX idx_tbl_auth_row_scopes_scope_id ON tbl_auth_row_scopes (scope_id)',
|
||||||
|
'CREATE INDEX idx_tbl_auth_row_scopes_target_type ON tbl_auth_row_scopes (target_type)',
|
||||||
|
'CREATE INDEX idx_tbl_auth_row_scopes_target_id ON tbl_auth_row_scopes (target_id)',
|
||||||
|
'CREATE INDEX idx_tbl_auth_row_scopes_table_name ON tbl_auth_row_scopes (table_name)'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 正在连接 PocketBase API...');
|
||||||
|
|
||||||
|
if (AUTH_TOKEN) {
|
||||||
|
pb.authStore.save(AUTH_TOKEN, null);
|
||||||
|
console.log('✅ 已使用 POCKETBASE_AUTH_TOKEN 载入认证状态。');
|
||||||
|
} else if (ADMIN_EMAIL && ADMIN_PASSWORD) {
|
||||||
|
await pb.collection('_superusers').authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
|
console.log('✅ 已使用超级管理员账号登录。');
|
||||||
|
} else {
|
||||||
|
throw new Error('缺少 PocketBase API 认证信息,请提供 POCKETBASE_AUTH_TOKEN 或 PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🚀 开始初始化 newpb 权限模型表结构...\n');
|
||||||
|
|
||||||
|
for (const collectionData of collections) {
|
||||||
|
await createOrUpdateCollection(collectionData);
|
||||||
|
}
|
||||||
|
|
||||||
|
await verifyCollections(collections);
|
||||||
|
console.log('\n🎉 newpb 权限模型表结构初始化并校验完成!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 初始化失败:', error.response?.data || error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrUpdateCollection(collectionData) {
|
||||||
|
console.log(`🔄 正在处理表: ${collectionData.name} ...`);
|
||||||
|
const payload = {
|
||||||
|
name: collectionData.name,
|
||||||
|
type: collectionData.type,
|
||||||
|
fields: collectionData.fields,
|
||||||
|
indexes: collectionData.indexes,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collections.create(payload);
|
||||||
|
console.log(`✅ ${collectionData.name} 创建完成。`);
|
||||||
|
} catch (error) {
|
||||||
|
const nameErrorCode = error.response?.data?.name?.code;
|
||||||
|
if (
|
||||||
|
error.status === 400
|
||||||
|
&& (nameErrorCode === 'validation_not_unique' || nameErrorCode === 'validation_collection_name_exists')
|
||||||
|
) {
|
||||||
|
const existing = await pb.collections.getOne(collectionData.name);
|
||||||
|
await pb.collections.update(existing.id, payload);
|
||||||
|
console.log(`♻️ ${collectionData.name} 已存在,已按最新结构更新。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`❌ 处理集合 ${collectionData.name} 失败:`, {
|
||||||
|
status: error.status,
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyCollections(targetCollections) {
|
||||||
|
console.log('\n🔍 开始校验 newpb 表结构与索引...');
|
||||||
|
|
||||||
|
for (const target of targetCollections) {
|
||||||
|
const remote = await pb.collections.getOne(target.name);
|
||||||
|
const remoteFieldNames = new Set((remote.fields || []).map((field) => field.name));
|
||||||
|
const missingFields = target.fields
|
||||||
|
.map((field) => field.name)
|
||||||
|
.filter((fieldName) => !remoteFieldNames.has(fieldName));
|
||||||
|
|
||||||
|
const remoteIndexes = new Set(remote.indexes || []);
|
||||||
|
const missingIndexes = target.indexes.filter((indexSql) => !remoteIndexes.has(indexSql));
|
||||||
|
|
||||||
|
if (remote.type !== target.type) {
|
||||||
|
throw new Error(`${target.name} 类型不匹配,期望 ${target.type},实际 ${remote.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingFields.length === 0 && missingIndexes.length === 0) {
|
||||||
|
console.log(`✅ ${target.name} 校验通过。`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`❌ ${target.name} 校验失败:`);
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
console.log(` - 缺失字段: ${missingFields.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (missingIndexes.length > 0) {
|
||||||
|
console.log(` - 缺失索引: ${missingIndexes.join(' | ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`${target.name} 结构与预期不一致`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
Reference in New Issue
Block a user