feat: 实现微信小程序后端接口与用户认证系统
新增微信登录/注册合一接口、资料完善接口和token刷新接口 重构用户服务层,支持自动维护用户类型和资料完整度 引入JWT认证中间件和请求验证中间件 更新文档与测试用例,支持dist构建部署
This commit is contained in:
13
README.md
13
README.md
@@ -54,6 +54,19 @@ Web_BAI_Manage_ApiServer/
|
||||
|
||||
## 快速开始
|
||||
|
||||
## 域名与 HTTPS 配置
|
||||
|
||||
项目正式环境后端域名为:`https://bai-api.blv-oa.com`
|
||||
|
||||
- 后端公开地址建议通过 `back-end/.env` 中的以下配置统一控制:
|
||||
- `APP_PROTOCOL=https`
|
||||
- `APP_DOMAIN=bai-api.blv-oa.com`
|
||||
- `APP_BASE_URL=https://bai-api.blv-oa.com`
|
||||
- 前端生产环境接口地址建议通过 `front-end/.env.production` 中的 `VUE_APP_BASE_URL` 控制:
|
||||
- `VUE_APP_BASE_URL='https://bai-api.blv-oa.com/api'`
|
||||
|
||||
如后续更换域名,优先修改 `.env` 文件,不建议在代码中硬编码域名。
|
||||
|
||||
### 后端服务
|
||||
|
||||
1. 进入后端目录
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
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 (placeholder)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=bai_management
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=password
|
||||
# Database Configuration (Pocketbase)
|
||||
POCKETBASE_API_URL=https://bai-api.blv-oa.com/pb/
|
||||
POCKETBASE_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3NzQzNjEzMzIsImlkIjoiazQ0aHI5MW90bnBydG10IiwicmVmcmVzaGFibGUiOmZhbHNlLCJ0eXBlIjoiYXV0aCJ9.qm4E6xYrDbEpAfdxZnHHRZs_EqiwHgDIIwSBz2k90Nk
|
||||
|
||||
# WeChat Configuration
|
||||
WECHAT_APPID=wx3bd7a7b19679da7a
|
||||
WECHAT_SECRET=57e40438c2a9151257b1927674db10e1
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
||||
JWT_EXPIRES_IN=2h
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,7 +6,17 @@ COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
CMD ["node", "dist/src/index.js"]
|
||||
23
back-end/dist/.env
vendored
Normal file
23
back-end/dist/.env
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3NzQzNjEzMzIsImlkIjoiazQ0aHI5MW90bnBydG10IiwicmVmcmVzaGFibGUiOmZhbHNlLCJ0eXBlIjoiYXV0aCJ9.qm4E6xYrDbEpAfdxZnHHRZs_EqiwHgDIIwSBz2k90Nk
|
||||
|
||||
# WeChat Configuration
|
||||
WECHAT_APPID=wx3bd7a7b19679da7a
|
||||
WECHAT_SECRET=57e40438c2a9151257b1927674db10e1
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
||||
JWT_EXPIRES_IN=2h
|
||||
13
back-end/dist/eslint.config.js
vendored
Normal file
13
back-end/dist/eslint.config.js
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = [
|
||||
{
|
||||
files: ['src/**/*.js', 'tests/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
rules: {
|
||||
semi: ['error', 'never'],
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
]
|
||||
3436
back-end/dist/package-lock.json
generated
vendored
Normal file
3436
back-end/dist/package-lock.json
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
32
back-end/dist/package.json
vendored
Normal file
32
back-end/dist/package.json
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
377
back-end/dist/spec/openapi.yaml
vendored
Normal file
377
back-end/dist/spec/openapi.yaml
vendored
Normal file
@@ -0,0 +1,377 @@
|
||||
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
|
||||
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
|
||||
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
Normal file
28
back-end/dist/src/config/env.js
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
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',
|
||||
}
|
||||
36
back-end/dist/src/controllers/wechatController.js
vendored
Normal file
36
back-end/dist/src/controllers/wechatController.js
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
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
Normal file
39
back-end/dist/src/index.js
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
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
Normal file
22
back-end/dist/src/middlewares/duplicateGuard.js
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
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
Normal file
19
back-end/dist/src/middlewares/errorHandler.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
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
Normal file
20
back-end/dist/src/middlewares/jwtAuth.js
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
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
Normal file
17
back-end/dist/src/middlewares/requestLogger.js
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
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
Normal file
15
back-end/dist/src/middlewares/requireJsonBody.js
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
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()
|
||||
}
|
||||
12
back-end/dist/src/middlewares/requireWechatOpenid.js
vendored
Normal file
12
back-end/dist/src/middlewares/requireWechatOpenid.js
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
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()
|
||||
}
|
||||
35
back-end/dist/src/middlewares/validateWechatAuth.js
vendored
Normal file
35
back-end/dist/src/middlewares/validateWechatAuth.js
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
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,
|
||||
}
|
||||
31
back-end/dist/src/middlewares/wechatHeadersAuth.js
vendored
Normal file
31
back-end/dist/src/middlewares/wechatHeadersAuth.js
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
24
back-end/dist/src/routes/apiRoutes.js
vendored
Normal file
24
back-end/dist/src/routes/apiRoutes.js
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
const express = require('express')
|
||||
const { success } = require('../utils/response')
|
||||
const wechatRoutes = require('./wechatRoutes')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.post('/test-helloworld', (req, res) => {
|
||||
return success(res, '请求成功', {
|
||||
message: 'Hello, World!',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/health', (req, res) => {
|
||||
return success(res, '服务运行正常', {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
router.use('/wechat', wechatRoutes)
|
||||
|
||||
module.exports = router
|
||||
14
back-end/dist/src/routes/wechatRoutes.js
vendored
Normal file
14
back-end/dist/src/routes/wechatRoutes.js
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
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
Normal file
12
back-end/dist/src/services/jwtService.js
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
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
Normal file
90
back-end/dist/src/services/pocketbaseService.js
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
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
Normal file
208
back-end/dist/src/services/userService.js
vendored
Normal file
@@ -0,0 +1,208 @@
|
||||
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
Normal file
117
back-end/dist/src/services/wechatService.js
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
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
Normal file
10
back-end/dist/src/utils/appError.js
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
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
Normal file
5
back-end/dist/src/utils/asyncHandler.js
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = function asyncHandler(fn) {
|
||||
return function wrappedHandler(req, res, next) {
|
||||
Promise.resolve(fn(req, res, next)).catch(next)
|
||||
}
|
||||
}
|
||||
27
back-end/dist/src/utils/logger.js
vendored
Normal file
27
back-end/dist/src/utils/logger.js
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
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
Normal file
20
back-end/dist/src/utils/response.js
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
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
Normal file
17
back-end/dist/src/utils/sanitize.js
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
}
|
||||
13
back-end/eslint.config.js
Normal file
13
back-end/eslint.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = [
|
||||
{
|
||||
files: ['src/**/*.js', 'tests/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
rules: {
|
||||
semi: ['error', 'never'],
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
]
|
||||
1401
back-end/package-lock.json
generated
1401
back-end/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,21 +2,27 @@
|
||||
"name": "web-bai-manage-api-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API server for BAI Management System",
|
||||
"main": "src/index.js",
|
||||
"main": "dist/src/index.js",
|
||||
"scripts": {
|
||||
"dev": "node src/index.js",
|
||||
"build": "echo 'No build needed for backend'",
|
||||
"test": "echo 'No tests implemented yet'",
|
||||
"lint": "echo 'No linting implemented yet'",
|
||||
"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"
|
||||
"dotenv": "^16.3.1",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fission-ai/openspec": "^1.0.0"
|
||||
"@fission-ai/openspec": "^1.0.0",
|
||||
"eslint": "^9.23.0",
|
||||
"supertest": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
52
back-end/scripts/build.js
Normal file
52
back-end/scripts/build.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const distDir = path.join(rootDir, 'dist')
|
||||
const sourceDirs = ['src', 'spec']
|
||||
const sourceFiles = ['package.json', 'package-lock.json', '.env', 'eslint.config.js']
|
||||
|
||||
function ensureCleanDir(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true })
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
|
||||
function copyRecursive(sourcePath, targetPath) {
|
||||
const stats = fs.statSync(sourcePath)
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
fs.mkdirSync(targetPath, { recursive: true })
|
||||
for (const entry of fs.readdirSync(sourcePath)) {
|
||||
copyRecursive(
|
||||
path.join(sourcePath, entry),
|
||||
path.join(targetPath, entry)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
|
||||
fs.copyFileSync(sourcePath, targetPath)
|
||||
}
|
||||
|
||||
function build() {
|
||||
ensureCleanDir(distDir)
|
||||
|
||||
for (const dir of sourceDirs) {
|
||||
const sourcePath = path.join(rootDir, dir)
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
copyRecursive(sourcePath, path.join(distDir, dir))
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const sourcePath = path.join(rootDir, file)
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
copyRecursive(sourcePath, path.join(distDir, file))
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Build completed. Deployable files generated in dist/.')
|
||||
}
|
||||
|
||||
build()
|
||||
@@ -1,14 +1,201 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: BAI Management API
|
||||
description: Backend API for BAI Management System
|
||||
description: BAI 管理系统后端 API 文档
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: https://bai-api.blv-oa.com
|
||||
description: BAI-api生产环境
|
||||
- url: http://localhost:3000
|
||||
description: Development server
|
||||
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
|
||||
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:
|
||||
/test-helloworld:
|
||||
get:
|
||||
/api/test-helloworld:
|
||||
post:
|
||||
tags: [系统]
|
||||
summary: Test endpoint
|
||||
description: Returns a hello world message
|
||||
responses:
|
||||
@@ -17,32 +204,174 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
example: Hello, World!
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
status:
|
||||
type: string
|
||||
example: success
|
||||
/health:
|
||||
get:
|
||||
summary: Health check
|
||||
description: Checks if the server is running
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/HelloWorldData'
|
||||
/api/health:
|
||||
post:
|
||||
tags: [系统]
|
||||
summary: 健康检查
|
||||
description: 检查服务是否正常运行
|
||||
responses:
|
||||
'200':
|
||||
description: Server is healthy
|
||||
description: 服务状态正常
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: healthy
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
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/src/config/env.js
Normal file
28
back-end/src/config/env.js
Normal file
@@ -0,0 +1,28 @@
|
||||
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',
|
||||
}
|
||||
36
back-end/src/controllers/wechatController.js
Normal file
36
back-end/src/controllers/wechatController.js
Normal file
@@ -0,0 +1,36 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,35 +1,39 @@
|
||||
const express = require('express');
|
||||
const dotenv = require('dotenv');
|
||||
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')
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config();
|
||||
function createApp() {
|
||||
const app = express()
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
app.use(express.json({ limit: '1mb' }))
|
||||
app.use(requestLogger)
|
||||
|
||||
// 解析JSON请求体
|
||||
app.use(express.json());
|
||||
app.use(env.apiPrefix, apiRoutes)
|
||||
|
||||
// 测试接口
|
||||
app.get('/test-helloworld', (req, res) => {
|
||||
res.json({
|
||||
message: 'Hello, World!',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success'
|
||||
});
|
||||
});
|
||||
app.use((req, res) => {
|
||||
return fail(res, 404, 'Route not found', {
|
||||
path: req.path,
|
||||
})
|
||||
})
|
||||
|
||||
// 健康检查接口
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
app.use(errorHandler)
|
||||
|
||||
// 启动服务器
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on port ${port}`);
|
||||
console.log(`Test endpoint: http://localhost:${port}/test-helloworld`);
|
||||
console.log(`Health check: http://localhost:${port}/health`);
|
||||
});
|
||||
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/src/middlewares/duplicateGuard.js
Normal file
22
back-end/src/middlewares/duplicateGuard.js
Normal file
@@ -0,0 +1,22 @@
|
||||
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/src/middlewares/errorHandler.js
Normal file
19
back-end/src/middlewares/errorHandler.js
Normal file
@@ -0,0 +1,19 @@
|
||||
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/src/middlewares/jwtAuth.js
Normal file
20
back-end/src/middlewares/jwtAuth.js
Normal file
@@ -0,0 +1,20 @@
|
||||
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/src/middlewares/requestLogger.js
Normal file
17
back-end/src/middlewares/requestLogger.js
Normal file
@@ -0,0 +1,17 @@
|
||||
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/src/middlewares/requireJsonBody.js
Normal file
15
back-end/src/middlewares/requireJsonBody.js
Normal file
@@ -0,0 +1,15 @@
|
||||
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()
|
||||
}
|
||||
12
back-end/src/middlewares/requireWechatOpenid.js
Normal file
12
back-end/src/middlewares/requireWechatOpenid.js
Normal file
@@ -0,0 +1,12 @@
|
||||
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()
|
||||
}
|
||||
35
back-end/src/middlewares/validateWechatAuth.js
Normal file
35
back-end/src/middlewares/validateWechatAuth.js
Normal file
@@ -0,0 +1,35 @@
|
||||
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,
|
||||
}
|
||||
31
back-end/src/middlewares/wechatHeadersAuth.js
Normal file
31
back-end/src/middlewares/wechatHeadersAuth.js
Normal file
@@ -0,0 +1,31 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
24
back-end/src/routes/apiRoutes.js
Normal file
24
back-end/src/routes/apiRoutes.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const express = require('express')
|
||||
const { success } = require('../utils/response')
|
||||
const wechatRoutes = require('./wechatRoutes')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.post('/test-helloworld', (req, res) => {
|
||||
return success(res, '请求成功', {
|
||||
message: 'Hello, World!',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/health', (req, res) => {
|
||||
return success(res, '服务运行正常', {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
router.use('/wechat', wechatRoutes)
|
||||
|
||||
module.exports = router
|
||||
14
back-end/src/routes/wechatRoutes.js
Normal file
14
back-end/src/routes/wechatRoutes.js
Normal file
@@ -0,0 +1,14 @@
|
||||
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/src/services/jwtService.js
Normal file
12
back-end/src/services/jwtService.js
Normal file
@@ -0,0 +1,12 @@
|
||||
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/src/services/pocketbaseService.js
Normal file
90
back-end/src/services/pocketbaseService.js
Normal file
@@ -0,0 +1,90 @@
|
||||
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/src/services/userService.js
Normal file
208
back-end/src/services/userService.js
Normal file
@@ -0,0 +1,208 @@
|
||||
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/src/services/wechatService.js
Normal file
117
back-end/src/services/wechatService.js
Normal file
@@ -0,0 +1,117 @@
|
||||
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/src/utils/appError.js
Normal file
10
back-end/src/utils/appError.js
Normal file
@@ -0,0 +1,10 @@
|
||||
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/src/utils/asyncHandler.js
Normal file
5
back-end/src/utils/asyncHandler.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = function asyncHandler(fn) {
|
||||
return function wrappedHandler(req, res, next) {
|
||||
Promise.resolve(fn(req, res, next)).catch(next)
|
||||
}
|
||||
}
|
||||
27
back-end/src/utils/logger.js
Normal file
27
back-end/src/utils/logger.js
Normal file
@@ -0,0 +1,27 @@
|
||||
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/src/utils/response.js
Normal file
20
back-end/src/utils/response.js
Normal file
@@ -0,0 +1,20 @@
|
||||
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/src/utils/sanitize.js
Normal file
17
back-end/src/utils/sanitize.js
Normal file
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
}
|
||||
188
back-end/tests/integration/wechat.test.js
Normal file
188
back-end/tests/integration/wechat.test.js
Normal file
@@ -0,0 +1,188 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const request = require('supertest')
|
||||
|
||||
const env = require('../../src/config/env')
|
||||
const { createApp } = require('../../src/index')
|
||||
const userService = require('../../src/services/userService')
|
||||
|
||||
test('POST /api/health 返回统一结构', async () => {
|
||||
const app = createApp()
|
||||
const response = await request(app).post('/api/health').send({})
|
||||
|
||||
assert.equal(response.status, 200)
|
||||
assert.equal(response.body.code, 200)
|
||||
assert.equal(response.body.msg, '服务运行正常')
|
||||
assert.equal(response.body.data.status, 'healthy')
|
||||
})
|
||||
|
||||
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!')
|
||||
})
|
||||
|
||||
test('未匹配路由返回统一 404', async () => {
|
||||
const app = createApp()
|
||||
const response = await request(app).get('/not-found-route')
|
||||
|
||||
assert.equal(response.status, 404)
|
||||
assert.equal(response.body.code, 404)
|
||||
assert.equal(response.body.msg, 'Route not found')
|
||||
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')
|
||||
})
|
||||
181
back-end/tests/userService.test.js
Normal file
181
back-end/tests/userService.test.js
Normal file
@@ -0,0 +1,181 @@
|
||||
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')
|
||||
})
|
||||
193
docs/ARCHIVE.md
Normal file
193
docs/ARCHIVE.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# OpenSpec 开发归档
|
||||
|
||||
## 归档日期
|
||||
|
||||
- 2026-03-20
|
||||
|
||||
## 归档范围
|
||||
|
||||
本次归档覆盖微信小程序后端交互相关接口的设计、实现、规范同步与部署调整,涉及:
|
||||
|
||||
- 接口路径统一收敛到 `/api`
|
||||
- 微信登录/注册合一
|
||||
- 微信资料完善接口重构
|
||||
- 微信手机号服务端换取
|
||||
- `users_type` 自动维护
|
||||
- JWT 认证与刷新 token
|
||||
- PocketBase Token 模式访问
|
||||
- OpenAPI 与项目文档同步
|
||||
- dist 构建与部署产物
|
||||
|
||||
---
|
||||
|
||||
## 一、接口演进结果
|
||||
|
||||
### 系统接口
|
||||
|
||||
- `POST /api/test-helloworld`
|
||||
- `POST /api/health`
|
||||
|
||||
### 微信小程序接口
|
||||
|
||||
- `POST /api/wechat/login`
|
||||
- 登录/注册合一
|
||||
- 接收 `users_wx_code`
|
||||
- 自动换取 `users_wx_openid`
|
||||
- 若无账号则自动创建游客账号
|
||||
- 返回 `status`、`is_info_complete`、`token`、完整用户信息
|
||||
|
||||
- `POST /api/wechat/profile`
|
||||
- 从 headers 读取 `users_wx_openid`
|
||||
- 需要 `Authorization`
|
||||
- body 接收:
|
||||
- `users_name`
|
||||
- `users_phone_code`
|
||||
- `users_picture`
|
||||
- 服务端调用微信接口换取真实手机号后写入 `users_phone`
|
||||
|
||||
- `POST /api/wechat/refresh-token`
|
||||
- 仅依赖 `users_wx_openid`
|
||||
- 不要求旧 `Authorization`
|
||||
- 返回新的 JWT token
|
||||
|
||||
---
|
||||
|
||||
## 二、关键业务规则
|
||||
|
||||
### 1. 用户类型 `users_type`
|
||||
|
||||
- 新账号初始化:`游客`
|
||||
- 当且仅当用户首次从:
|
||||
- `users_name` 为空
|
||||
- `users_phone` 为空
|
||||
- `users_picture` 为空
|
||||
|
||||
变为:
|
||||
- 三项全部完整
|
||||
|
||||
时,自动升级为:`注册用户`
|
||||
|
||||
- 后续资料修改不再覆盖已确定类型
|
||||
|
||||
### 2. 用户资料完整度 `is_info_complete`
|
||||
|
||||
以下三项同时存在时为 `true`:
|
||||
|
||||
- `users_name`
|
||||
- `users_phone`
|
||||
- `users_picture`
|
||||
|
||||
否则为 `false`
|
||||
|
||||
### 3. 微信手机号获取
|
||||
|
||||
服务端使用微信官方接口:
|
||||
|
||||
- `getuserphonenumber`
|
||||
|
||||
通过 `users_phone_code` 换取真实手机号,再写入数据库字段 `users_phone`。
|
||||
|
||||
---
|
||||
|
||||
## 三、鉴权规则
|
||||
|
||||
### 标准请求头
|
||||
|
||||
- `Authorization: Bearer <token>`
|
||||
- `users_wx_openid: <openid>`
|
||||
|
||||
### 当前规则
|
||||
|
||||
- `/api/wechat/login`:不需要 token
|
||||
- `/api/wechat/profile`:需要 `users_wx_openid + Authorization`
|
||||
- `/api/wechat/refresh-token`:**只需要** `users_wx_openid`
|
||||
|
||||
---
|
||||
|
||||
## 四、请求格式规则
|
||||
|
||||
所有微信写接口统一要求:
|
||||
|
||||
- `Content-Type: application/json`
|
||||
|
||||
不符合时返回:
|
||||
|
||||
- `415 请求体必须为 application/json`
|
||||
|
||||
---
|
||||
|
||||
## 五、PocketBase 访问策略
|
||||
|
||||
当前统一使用:
|
||||
|
||||
- `POCKETBASE_API_URL`
|
||||
- `POCKETBASE_AUTH_TOKEN`
|
||||
|
||||
已移除:
|
||||
|
||||
- `POCKETBASE_USER_NAME`
|
||||
- `POCKETBASE_PASSWORD`
|
||||
|
||||
---
|
||||
|
||||
## 六、部署与产物
|
||||
|
||||
后端已支持 dist 构建:
|
||||
|
||||
- `npm run build`
|
||||
- 产物目录:`back-end/dist/`
|
||||
|
||||
当前发布目录包含:
|
||||
|
||||
- `dist/src/`
|
||||
- `dist/spec/`
|
||||
- `dist/package.json`
|
||||
- `dist/package-lock.json`
|
||||
- `dist/.env`
|
||||
|
||||
生产启动方式:
|
||||
|
||||
- `npm start`
|
||||
- 实际运行:`node dist/src/index.js`
|
||||
|
||||
---
|
||||
|
||||
## 七、文档同步结果
|
||||
|
||||
已同步更新:
|
||||
|
||||
- `back-end/spec/openapi.yaml`
|
||||
- `docs/api.md`
|
||||
- `docs/deployment.md`
|
||||
- `README.md`
|
||||
|
||||
其中 `openapi.yaml` 已与当前真实接口行为对齐。
|
||||
|
||||
---
|
||||
|
||||
## 八、质量验证结果
|
||||
|
||||
本次归档前已验证通过:
|
||||
|
||||
- `npm run lint`
|
||||
- `npm run test`
|
||||
- `npm run build`
|
||||
|
||||
测试覆盖包括:
|
||||
|
||||
- 系统接口
|
||||
- 统一 404
|
||||
- 登录/注册合一
|
||||
- 非 JSON 拒绝
|
||||
- 资料更新
|
||||
- token 刷新
|
||||
- `users_type` 升级逻辑
|
||||
- `is_info_complete` 返回逻辑
|
||||
|
||||
---
|
||||
|
||||
## 九、当前已知边界
|
||||
|
||||
1. `refresh-token` 当前仅依赖 `users_wx_openid`,未校验旧 token
|
||||
2. 微信手机号能力依赖微信官方 `access_token` 与 `users_phone_code`
|
||||
3. 当前后端为 JavaScript + Express 架构,未引入 TypeScript 编译链
|
||||
511
docs/api.md
511
docs/api.md
@@ -1,332 +1,285 @@
|
||||
# API接口文档
|
||||
# API 接口文档
|
||||
|
||||
## 接口概述
|
||||
## 文档说明
|
||||
|
||||
本文档定义了微信小程序前端调用的API接口,包括认证、用户、数据等相关接口。
|
||||
本文档描述当前项目中**已经真实实现**并可直接调用的后端接口。
|
||||
当前接口统一特征如下:
|
||||
|
||||
## 基础信息
|
||||
- 基础路径(生产):`https://bai-api.blv-oa.com/api`
|
||||
- 基础路径(本地):`http://localhost:3000/api`
|
||||
- 响应格式:JSON
|
||||
- 业务响应结构统一为:`code`、`msg`、`data`
|
||||
- 当前公开接口统一使用 **POST** 方法
|
||||
- 微信写接口统一要求 `Content-Type: application/json`
|
||||
|
||||
- API基础路径: `http://localhost:3000/api`
|
||||
- 响应格式: JSON
|
||||
- 认证方式: JWT
|
||||
---
|
||||
|
||||
## 认证接口
|
||||
## 一、统一响应格式
|
||||
|
||||
### 1. 用户登录
|
||||
|
||||
**接口地址**: `/auth/login`
|
||||
**请求方式**: POST
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必选 | 描述 |
|
||||
|-------|------|------|------|
|
||||
| username | string | 是 | 用户名 |
|
||||
| password | string | 是 | 密码 |
|
||||
|
||||
**响应示例**:
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "登录成功",
|
||||
"msg": "操作成功",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 失败响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"msg": "错误信息",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、系统接口
|
||||
|
||||
### 1. HelloWorld 测试接口
|
||||
|
||||
- **接口地址**:`/test-helloworld`
|
||||
- **请求方式**:`POST`
|
||||
- **请求头**:无特殊要求
|
||||
- **请求体**:可为空 `{}`
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "请求成功",
|
||||
"data": {
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"message": "Hello, World!",
|
||||
"timestamp": "2026-03-20T00:00:00.000Z",
|
||||
"status": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 健康检查接口
|
||||
|
||||
- **接口地址**:`/health`
|
||||
- **请求方式**:`POST`
|
||||
- **请求头**:无特殊要求
|
||||
- **请求体**:可为空 `{}`
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "服务运行正常",
|
||||
"data": {
|
||||
"status": "healthy",
|
||||
"timestamp": "2026-03-20T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、微信小程序接口
|
||||
|
||||
## 1. 登录/注册合一
|
||||
|
||||
- **接口地址**:`/wechat/login`
|
||||
- **请求方式**:`POST`
|
||||
- **请求头**:
|
||||
- `Content-Type: application/json`
|
||||
|
||||
### 请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"users_wx_code": "0a1b2c3d4e5f6g"
|
||||
}
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `users_wx_code` | string | 是 | 微信小程序登录临时凭证 code,用于换取 `users_wx_openid` |
|
||||
|
||||
### 处理逻辑
|
||||
|
||||
- 使用 `users_wx_code` 向微信服务端换取 `users_wx_openid`
|
||||
- 如果数据库中不存在该用户,则自动创建新账号:
|
||||
- 初始化 `users_type = 游客`
|
||||
- 如果数据库中已存在该用户,则直接登录
|
||||
- 返回:
|
||||
- `status`
|
||||
- `is_info_complete`
|
||||
- `token`
|
||||
- 完整用户信息
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "登录成功",
|
||||
"data": {
|
||||
"status": "login_success",
|
||||
"is_info_complete": true,
|
||||
"token": "jwt-token",
|
||||
"user": {
|
||||
"id": "123",
|
||||
"username": "admin",
|
||||
"nickname": "管理员"
|
||||
"users_id": "U202603190001",
|
||||
"users_type": "注册用户",
|
||||
"users_name": "张三",
|
||||
"users_phone": "13800138000",
|
||||
"users_phone_masked": "138****8000",
|
||||
"users_picture": "https://example.com/avatar.png",
|
||||
"users_wx_openid": "oAbCdEfGh123456789",
|
||||
"company_id": "C10001",
|
||||
"company": null,
|
||||
"pb_id": "abc123xyz",
|
||||
"created": "2026-03-20T00:00:00.000Z",
|
||||
"updated": "2026-03-20T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 用户注册
|
||||
---
|
||||
|
||||
**接口地址**: `/auth/register`
|
||||
**请求方式**: POST
|
||||
**请求参数**:
|
||||
## 2. 完善/修改用户资料
|
||||
|
||||
| 参数名 | 类型 | 必选 | 描述 |
|
||||
|-------|------|------|------|
|
||||
| username | string | 是 | 用户名 |
|
||||
| password | string | 是 | 密码 |
|
||||
| nickname | string | 是 | 昵称 |
|
||||
- **接口地址**:`/wechat/profile`
|
||||
- **请求方式**:`POST`
|
||||
- **请求头**:
|
||||
- `Content-Type: application/json`
|
||||
- `users_wx_openid: 微信用户唯一标识`
|
||||
- `Authorization: Bearer <token>`
|
||||
|
||||
**响应示例**:
|
||||
### 请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"users_name": "张三",
|
||||
"users_phone_code": "2b7d9f2e3c4a5b6d7e8f",
|
||||
"users_picture": "https://example.com/avatar.png"
|
||||
}
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `users_name` | string | 是 | 用户姓名 |
|
||||
| `users_phone_code` | string | 是 | 微信手机号获取凭证 code,后端将据此换取真实手机号 |
|
||||
| `users_picture` | string | 是 | 用户头像 URL |
|
||||
|
||||
### 处理逻辑
|
||||
|
||||
- 从请求头 `users_wx_openid` 读取当前用户身份
|
||||
- 校验 `Authorization`
|
||||
- 不再从 body 读取 `users_wx_code`
|
||||
- 使用 `users_phone_code` 调微信官方接口换取真实手机号
|
||||
- 将真实手机号写入数据库字段 `users_phone`
|
||||
- 若用户首次从“三项资料均为空”变为“三项资料均完整”,则将 `users_type` 从 `游客` 升级为 `注册用户`
|
||||
- 返回更新后的完整用户信息
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "注册成功",
|
||||
"msg": "信息更新成功",
|
||||
"data": {
|
||||
"id": "123",
|
||||
"username": "user1",
|
||||
"nickname": "新用户"
|
||||
"status": "update_success",
|
||||
"user": {
|
||||
"users_id": "U202603190001",
|
||||
"users_type": "注册用户",
|
||||
"users_name": "张三",
|
||||
"users_phone": "13800138000",
|
||||
"users_phone_masked": "138****8000",
|
||||
"users_picture": "https://example.com/avatar.png",
|
||||
"users_wx_openid": "oAbCdEfGh123456789",
|
||||
"company_id": "",
|
||||
"company": null,
|
||||
"pb_id": "abc123xyz",
|
||||
"created": "2026-03-20T00:00:00.000Z",
|
||||
"updated": "2026-03-20T00:10:00.000Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 用户接口
|
||||
---
|
||||
|
||||
### 1. 获取用户信息
|
||||
## 3. 刷新 token
|
||||
|
||||
**接口地址**: `/user/info`
|
||||
**请求方式**: GET
|
||||
**请求头**:
|
||||
- Authorization: Bearer {token}
|
||||
- **接口地址**:`/wechat/refresh-token`
|
||||
- **请求方式**:`POST`
|
||||
- **请求头**:
|
||||
- `users_wx_openid: 微信用户唯一标识`
|
||||
|
||||
**响应示例**:
|
||||
> 说明:本接口**不要求旧 `Authorization`**。
|
||||
|
||||
### 请求体
|
||||
|
||||
- 无 body 参数,可传 `{}` 或空体
|
||||
|
||||
### 处理逻辑
|
||||
|
||||
- 仅通过请求头中的 `users_wx_openid` 定位用户
|
||||
- 若用户存在,则签发新的 JWT token
|
||||
- 若用户不存在,则返回 `404`
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"msg": "刷新成功",
|
||||
"data": {
|
||||
"id": "123",
|
||||
"username": "admin",
|
||||
"nickname": "管理员",
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
"createdAt": "2026-03-18T00:00:00Z"
|
||||
"token": "new-jwt-token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 更新用户信息
|
||||
---
|
||||
|
||||
**接口地址**: `/user/update`
|
||||
**请求方式**: PUT
|
||||
**请求头**:
|
||||
- Authorization: Bearer {token}
|
||||
**请求参数**:
|
||||
## 四、错误码说明
|
||||
|
||||
| 参数名 | 类型 | 必选 | 描述 |
|
||||
|-------|------|------|------|
|
||||
| nickname | string | 否 | 昵称 |
|
||||
| avatar | string | 否 | 头像URL |
|
||||
| 错误码 | 说明 |
|
||||
|---|---|
|
||||
| `200` | 成功 |
|
||||
| `400` | 请求参数错误 |
|
||||
| `401` | 请求头缺失、令牌无效或用户身份不匹配 |
|
||||
| `404` | 用户不存在或路由不存在 |
|
||||
| `415` | 请求体不是 `application/json` |
|
||||
| `429` | 请求过于频繁 |
|
||||
| `500` | 服务器内部错误 |
|
||||
|
||||
**响应示例**:
|
||||
---
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "更新成功",
|
||||
"data": {
|
||||
"id": "123",
|
||||
"username": "admin",
|
||||
"nickname": "新昵称",
|
||||
"avatar": "https://example.com/new-avatar.jpg"
|
||||
}
|
||||
}
|
||||
## 五、调用建议
|
||||
|
||||
### 1. 所有微信写接口都使用 JSON
|
||||
|
||||
请求头应设置:
|
||||
|
||||
```http
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
## 数据接口
|
||||
### 2. 资料接口与资料详情接口都要带标准 JWT 头
|
||||
|
||||
### 1. 获取数据列表
|
||||
|
||||
**接口地址**: `/data/list`
|
||||
**请求方式**: GET
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必选 | 描述 |
|
||||
|-------|------|------|------|
|
||||
| page | number | 否 | 页码,默认1 |
|
||||
| size | number | 否 | 每页条数,默认10 |
|
||||
| keyword | string | 否 | 搜索关键词 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "数据1",
|
||||
"content": "内容1",
|
||||
"createdAt": "2026-03-18T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"size": 10
|
||||
}
|
||||
}
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### 2. 获取数据详情
|
||||
### 3. `refresh-token` 接口当前只需要:
|
||||
|
||||
**接口地址**: `/data/detail/{id}`
|
||||
**请求方式**: GET
|
||||
**路径参数**:
|
||||
- id: 数据ID
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"id": "1",
|
||||
"title": "数据1",
|
||||
"content": "内容1",
|
||||
"createdAt": "2026-03-18T00:00:00Z",
|
||||
"updatedAt": "2026-03-18T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```http
|
||||
users_wx_openid: <openid>
|
||||
```
|
||||
|
||||
## AI接口
|
||||
|
||||
### 1. 智能问答
|
||||
|
||||
**接口地址**: `/ai/chat`
|
||||
**请求方式**: POST
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必选 | 描述 |
|
||||
|-------|------|------|------|
|
||||
| question | string | 是 | 问题 |
|
||||
| context | string | 否 | 上下文 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"answer": "这是AI的回答",
|
||||
"thinking": "AI的思考过程",
|
||||
"tokens": 100
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 视频接口
|
||||
|
||||
### 1. 上传视频
|
||||
|
||||
**接口地址**: `/video/upload`
|
||||
**请求方式**: POST
|
||||
**请求头**:
|
||||
- Content-Type: multipart/form-data
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必选 | 描述 |
|
||||
|-------|------|------|------|
|
||||
| video | file | 是 | 视频文件 |
|
||||
| title | string | 是 | 视频标题 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "上传成功",
|
||||
"data": {
|
||||
"id": "1",
|
||||
"title": "视频标题",
|
||||
"url": "https://example.com/video.mp4",
|
||||
"duration": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取视频列表
|
||||
|
||||
**接口地址**: `/video/list`
|
||||
**请求方式**: GET
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必选 | 描述 |
|
||||
|-------|------|------|------|
|
||||
| page | number | 否 | 页码,默认1 |
|
||||
| size | number | 否 | 每页条数,默认10 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "视频1",
|
||||
"url": "https://example.com/video1.mp4",
|
||||
"duration": 60,
|
||||
"createdAt": "2026-03-18T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"size": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| 错误码 | 描述 |
|
||||
|-------|------|
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权,请登录 |
|
||||
| 403 | 禁止访问 |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
## 调用示例
|
||||
|
||||
### 使用axios调用
|
||||
|
||||
```javascript
|
||||
// 登录
|
||||
axios.post('/api/auth/login', {
|
||||
username: 'admin',
|
||||
password: '123456'
|
||||
}).then(response => {
|
||||
const token = response.data.data.token;
|
||||
// 存储token
|
||||
localStorage.setItem('token', token);
|
||||
});
|
||||
|
||||
// 带认证的请求
|
||||
axios.get('/api/user/info', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 使用fetch调用
|
||||
|
||||
```javascript
|
||||
// 登录
|
||||
fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: 'admin',
|
||||
password: '123456'
|
||||
})
|
||||
}).then(response => response.json())
|
||||
.then(data => {
|
||||
const token = data.data.token;
|
||||
// 存储token
|
||||
localStorage.setItem('token', token);
|
||||
});
|
||||
|
||||
// 带认证的请求
|
||||
fetch('/api/user/info', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}).then(response => response.json())
|
||||
.then(data => console.log(data));
|
||||
```
|
||||
不需要旧 `Authorization`。
|
||||
|
||||
@@ -91,13 +91,14 @@ npm run test
|
||||
|
||||
### 数据库操作
|
||||
|
||||
使用Pocketbase作为数据库,提供轻量级的数据存储解决方案。
|
||||
使用Pocketbase作为数据库,提供轻量级的数据存储解决方案。通过Pocketbase API进行数据操作,支持CRUD操作和实时数据同步。
|
||||
|
||||
### 环境变量
|
||||
|
||||
环境变量配置位于 `.env` 文件,包括:
|
||||
- 服务器端口
|
||||
- 数据库连接信息
|
||||
- Pocketbase API URL
|
||||
- Pocketbase认证信息
|
||||
- JWT密钥
|
||||
- 其他配置参数
|
||||
|
||||
|
||||
@@ -60,12 +60,20 @@ wget -O install.sh http://download.bt.cn/install/install-ubuntu_6.0.sh && sudo b
|
||||
|
||||
## 后端部署
|
||||
|
||||
后端建议采用 **dist 发布目录部署**,即:
|
||||
|
||||
1. 在构建机执行 `npm run build`
|
||||
2. 生成 `back-end/dist/` 发布产物
|
||||
3. 服务器仅部署 `dist/`、`package.json`、`package-lock.json` 与 `.env`
|
||||
4. 服务器执行 `npm install --omit=dev`
|
||||
5. 服务器执行 `npm start`
|
||||
|
||||
### 1. 创建Dockerfile
|
||||
|
||||
在 `back-end` 目录创建 `Dockerfile` 文件:
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-alpine
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -73,10 +81,57 @@ COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
CMD ["node", "dist/src/index.js"]
|
||||
```
|
||||
|
||||
### 1.1 本地构建与上传发布包
|
||||
|
||||
在 `back-end/` 目录执行:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建成功后会生成:
|
||||
|
||||
```text
|
||||
back-end/dist/
|
||||
src/
|
||||
spec/
|
||||
package.json
|
||||
package-lock.json
|
||||
.env
|
||||
eslint.config.js
|
||||
```
|
||||
|
||||
如果你采用服务器源码分离部署,建议上传以下内容到服务器:
|
||||
|
||||
- `dist/`
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- `.env`
|
||||
|
||||
然后在服务器执行:
|
||||
|
||||
```bash
|
||||
npm install --omit=dev
|
||||
npm start
|
||||
```
|
||||
|
||||
### 2. 创建docker-compose.yml
|
||||
@@ -202,20 +257,21 @@ chmod +x deploy.sh
|
||||
3. 配置前端代理:
|
||||
- 代理名称:frontend
|
||||
- 目标URL:http://localhost:80
|
||||
- 发送域名:你的域名
|
||||
- 发送域名:`bai-api.blv-oa.com`(如前后端分域,请按实际前端域名填写)
|
||||
4. 配置后端代理:
|
||||
- 代理名称:backend
|
||||
- 目标URL:http://localhost:3000
|
||||
- 发送域名:你的域名
|
||||
- 发送域名:`bai-api.blv-oa.com`
|
||||
- 路径:/api
|
||||
5. 点击「保存」
|
||||
|
||||
### 3. 配置SSL证书(可选)
|
||||
### 3. 配置SSL证书(必须)
|
||||
|
||||
1. 进入网站设置
|
||||
2. 点击「SSL」→「Let's Encrypt」
|
||||
3. 申请并安装SSL证书
|
||||
4. 开启「强制HTTPS」
|
||||
5. 确保域名 `bai-api.blv-oa.com` 已正确解析到服务器公网 IP
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
@@ -227,6 +283,9 @@ chmod +x deploy.sh
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
APP_PROTOCOL=https
|
||||
APP_DOMAIN=bai-api.blv-oa.com
|
||||
APP_BASE_URL=https://bai-api.blv-oa.com
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=db
|
||||
@@ -238,7 +297,7 @@ JWT_SECRET=your_jwt_secret_key
|
||||
JWT_EXPIRES_IN=24h
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=*
|
||||
CORS_ORIGIN=https://bai-api.blv-oa.com
|
||||
```
|
||||
|
||||
### 前端环境变量
|
||||
@@ -246,11 +305,21 @@ CORS_ORIGIN=*
|
||||
在 `front-end/.env.production` 文件中配置:
|
||||
|
||||
```env
|
||||
VUE_APP_API_BASE_URL=http://your-domain.com/api
|
||||
VUE_APP_BASE_URL=https://bai-api.blv-oa.com/api
|
||||
VUE_APP_TITLE=BAI管理系统
|
||||
VUE_APP_VERSION=1.0.0
|
||||
```
|
||||
|
||||
## 域名解析与 HTTPS 部署建议
|
||||
|
||||
正式环境建议按以下方式部署:
|
||||
|
||||
1. 将域名 `bai-api.blv-oa.com` 的 DNS A 记录指向服务器公网 IP
|
||||
2. 宝塔/Nginx 为该域名签发并启用 SSL 证书
|
||||
3. Nginx 对外暴露 `443`,再反向代理到容器内 `backend:3000`
|
||||
4. 前端生产环境接口地址统一使用:`https://bai-api.blv-oa.com/api`
|
||||
5. 后端对外公开地址统一使用 `APP_BASE_URL=https://bai-api.blv-oa.com`
|
||||
|
||||
## 数据库配置
|
||||
|
||||
### Pocketbase设置
|
||||
@@ -265,6 +334,18 @@ VUE_APP_VERSION=1.0.0
|
||||
|
||||
## 监控与维护
|
||||
|
||||
### 后端发布命令
|
||||
|
||||
后端推荐命令:
|
||||
|
||||
```bash
|
||||
# 构建发布产物
|
||||
npm run build
|
||||
|
||||
# 生产启动
|
||||
npm start
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
|
||||
42
docs/example.md
Normal file
42
docs/example.md
Normal file
@@ -0,0 +1,42 @@
|
||||
// 获取微信小程序OpenID
|
||||
private async Task<string> GetWxOpenIdAsync(string code)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appId = configuration["WeChat:AppId"];
|
||||
var appSecret = configuration["WeChat:AppSecret"];
|
||||
|
||||
if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(appSecret))
|
||||
{
|
||||
throw new Exception("微信小程序配置缺失");
|
||||
}
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
var url = $"https://api.weixin.qq.com/sns/jscode2session?appid={appId}&secret={appSecret}&js_code={code}&grant_type=authorization_code";
|
||||
|
||||
var response = await httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
var jsonDocument = JsonDocument.Parse(responseContent);
|
||||
|
||||
if (jsonDocument.RootElement.TryGetProperty("openid", out var openidElement))
|
||||
{
|
||||
return openidElement.GetString();
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果有错误信息,抛出异常
|
||||
if (jsonDocument.RootElement.TryGetProperty("errcode", out var errcodeElement) &&
|
||||
jsonDocument.RootElement.TryGetProperty("errmsg", out var errmsgElement))
|
||||
{
|
||||
throw new Exception($"获取OpenID失败: {errcodeElement.GetInt32()} - {errmsgElement.GetString()}");
|
||||
}
|
||||
throw new Exception("获取OpenID失败: 响应中未包含openid");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"获取微信OpenID时发生错误: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# 生产环境,VUE_APP_BASE_URL可以选择自己配置成需要的接口地址,如"https://api.xxx.com"
|
||||
# 此文件修改后需要重启项目
|
||||
# 生产环境接口地址,部署到正式域名后请保持与后端公开地址一致
|
||||
# 此文件修改后需要重新构建项目
|
||||
NODE_ENV=production
|
||||
VUE_APP_BASE_URL='/vab-mock-server'
|
||||
VUE_APP_BASE_URL='https://bai-api.blv-oa.com/api'
|
||||
|
||||
96
script/database_schema.md
Normal file
96
script/database_schema.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 平台后台数据库表结构 (PocketBase)
|
||||
|
||||
本方案采用纯业务 ID 关联模式。PocketBase 底层的 `id` 字段由系统自动维护,业务逻辑中完全使用自定义的 `_id` 字段进行读写和关联。
|
||||
|
||||
---
|
||||
|
||||
### 1. tbl_system_dict (系统词典)
|
||||
**类型:** Base Collection
|
||||
|
||||
| 字段名 | 类型 | 备注 |
|
||||
| :--- | :--- | :--- |
|
||||
| system_dict_id | text | 自定义词id |
|
||||
| dict_name | text | 词典名称 |
|
||||
| dict_word_enum | text | 词典枚举 |
|
||||
| dict_word_description | text | 词典描述 |
|
||||
| dict_word_is_enabled | bool | 是否有效 |
|
||||
| dict_word_sort_order | number | 显示顺序 |
|
||||
| dict_word_parent_id | text | 实现父级字段 (存储其他 dict 的 system_dict_id) |
|
||||
| dict_word_remark | text | 备注 |
|
||||
|
||||
**索引规划 (Indexes):**
|
||||
* `CREATE UNIQUE INDEX` 针对 `system_dict_id` (确保业务主键唯一)
|
||||
* `CREATE INDEX` 针对 `dict_word_parent_id` (加速父子级联查询)
|
||||
|
||||
---
|
||||
|
||||
### 2. tbl_company (公司)
|
||||
**类型:** Base Collection
|
||||
|
||||
| 字段名 | 类型 | 备注 |
|
||||
| :--- | :--- | :--- |
|
||||
| company_id | text | 自定义公司id |
|
||||
| company_name | text | 公司名称 |
|
||||
| company_type | text | 公司类型 |
|
||||
| company_entity | text | 公司法人 |
|
||||
| company_usci | text | 统一社会信用代码 |
|
||||
| company_nationality | text | 国家 |
|
||||
| company_province | text | 省份 |
|
||||
| company_city | text | 城市 |
|
||||
| company_postalcode | text | 邮编 |
|
||||
| company_add | text | 地址 |
|
||||
| company_status | text | 公司状态 |
|
||||
| company_level | text | 公司等级 |
|
||||
| company_remark | text | 备注 |
|
||||
|
||||
**索引规划 (Indexes):**
|
||||
* `CREATE UNIQUE INDEX` 针对 `company_id` (确保业务主键唯一)
|
||||
* `CREATE INDEX` 针对 `company_usci` (加速信用代码检索)
|
||||
|
||||
---
|
||||
|
||||
### 3. tbl_user_groups (用户组)
|
||||
**类型:** Base Collection
|
||||
|
||||
| 字段名 | 类型 | 备注 |
|
||||
| :--- | :--- | :--- |
|
||||
| usergroups_id | text | 自定义用户组id |
|
||||
| usergroups_name | text | 用户组名 |
|
||||
| usergroups_level | number | 权限等级 |
|
||||
| usergroups_remark | text | 备注 |
|
||||
|
||||
**索引规划 (Indexes):**
|
||||
* `CREATE UNIQUE INDEX` 针对 `usergroups_id` (确保业务主键唯一)
|
||||
|
||||
---
|
||||
|
||||
### 4. tbl_users (用户)
|
||||
**类型:** Base Collection (采用纯业务记录,不使用系统 Auth,以便完美适配图示字段)
|
||||
|
||||
| 字段名 | 类型 | 备注 |
|
||||
| :--- | :--- | :--- |
|
||||
| users_id | text | 自定义用户id |
|
||||
| users_name | text | 用户名 |
|
||||
| users_idtype | text | 证件类别 |
|
||||
| users_id_number | text | 证件号 |
|
||||
| users_phone | text | 用户电话号码 |
|
||||
| users_wx_openid | text | 微信号 |
|
||||
| users_level | text | 用户等级 |
|
||||
| users_type | text | 用户类型 |
|
||||
| users_status | text | 用户状态 |
|
||||
| company_id | text | 公司id (存储 tbl_company.company_id) |
|
||||
| users_parent_id | text | 用户父级id (存储 tbl_users.users_id) |
|
||||
| users_promo_code | text | 用户推广码 |
|
||||
| users_id_pic_a | file | 用户证件照片(正) |
|
||||
| users_id_pic_b | file | 用户证件照片(反) |
|
||||
| users_title_picture | file | 用户资质照片 |
|
||||
| users_picture | file | 用户头像 |
|
||||
| usergroups_id | text | 用户组id (存储 tbl_user_groups.usergroups_id) |
|
||||
|
||||
**索引规划 (Indexes):**
|
||||
* `CREATE UNIQUE INDEX` 针对 `users_id` (确保业务主键唯一)
|
||||
* `CREATE UNIQUE INDEX` 针对 `users_phone` (确保手机号唯一,加速登录查询)
|
||||
* `CREATE UNIQUE INDEX` 针对 `users_wx_openid` (确保微信开放ID唯一)
|
||||
* `CREATE INDEX` 针对 `company_id`, `usergroups_id`, `users_parent_id` (加速这三个高频业务外键的匹配查询)
|
||||
|
||||
|
||||
14
script/node_modules/.package-lock.json
generated
vendored
Normal file
14
script/node_modules/.package-lock.json
generated
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "script",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/pocketbase": {
|
||||
"version": "0.26.8",
|
||||
"resolved": "https://registry.npmmirror.com/pocketbase/-/pocketbase-0.26.8.tgz",
|
||||
"integrity": "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
855
script/node_modules/pocketbase/CHANGELOG.md
generated
vendored
Normal file
855
script/node_modules/pocketbase/CHANGELOG.md
generated
vendored
Normal file
@@ -0,0 +1,855 @@
|
||||
## 0.26.8
|
||||
|
||||
- Properly reject the `authWithOAuth2()` `Promise` when manually calling `pb.cancelRequest(requestKey)` _(previously the manual cancellation didn't account for the waiting realtime subscription)_.
|
||||
|
||||
|
||||
## 0.26.7
|
||||
|
||||
- Normalized `pb.files.getURL()` to serialize the URL query params in the same manner as in the fetch methods (e.g. passing `null` or `undefined` as query param value will be skipped from the generated URL).
|
||||
|
||||
|
||||
## 0.26.6
|
||||
|
||||
- Fixed abort request error detection on React Native Android/iOS ([#361](https://github.com/pocketbase/js-sdk/pull/361); thanks @nathanstitt).
|
||||
|
||||
- Updated the default `getFullList()` batch size to 1000 for consistency with the Dart SDK and the v0.23+ API limits.
|
||||
|
||||
|
||||
## 0.26.5
|
||||
|
||||
- Fixed abort request error detection on Safari introduced with the previous release because it seems to throw `DOMException.SyntaxError` on `response.json()` failure ([#pocketbase/pocketbase#7369](https://github.com/pocketbase/pocketbase/issues/7369)).
|
||||
|
||||
|
||||
## 0.26.4
|
||||
|
||||
- Catch aborted request error during `response.json()` failure _(e.g. in case of tcp connection reset)_ and rethrow it as normalized `ClientResponseError.isAbort=true` error.
|
||||
|
||||
|
||||
## 0.26.3
|
||||
|
||||
- Fixed outdated `OAuth2Provider` TS fields ([pocketbase/site#110](https://github.com/pocketbase/site/pull/110)).
|
||||
|
||||
|
||||
## 0.26.2
|
||||
|
||||
- Allow body object without constructor ([#352](https://github.com/pocketbase/js-sdk/issues/352)).
|
||||
|
||||
|
||||
## 0.26.1
|
||||
|
||||
- Set the `cause` property of `ClientResponseError` to the original thrown error/data for easier debugging ([#349](https://github.com/pocketbase/js-sdk/pull/349); thanks @shish).
|
||||
|
||||
|
||||
## 0.26.0
|
||||
|
||||
- Ignore `undefined` properties when submitting an object that has `Blob`/`File` fields (_which is under the hood converted to `FormData`_)
|
||||
for consistency with how `JSON.stringify` works (see [pocketbase#6731](https://github.com/pocketbase/pocketbase/issues/6731#issuecomment-2812382827)).
|
||||
|
||||
|
||||
## 0.25.2
|
||||
|
||||
- Removed unnecessary checks in `serializeQueryParams` and added automated tests.
|
||||
|
||||
|
||||
## 0.25.1
|
||||
|
||||
- Ignore query parameters with `undefined` value ([#330](https://github.com/pocketbase/js-sdk/issues/330)).
|
||||
|
||||
|
||||
## 0.25.0
|
||||
|
||||
- Added `pb.crons` service to interact with the cron Web APIs.
|
||||
|
||||
|
||||
## 0.24.0
|
||||
|
||||
- Added support for assigning `FormData` as body to individual batch requests ([pocketbase#6145](https://github.com/pocketbase/pocketbase/discussions/6145)).
|
||||
|
||||
|
||||
## 0.23.0
|
||||
|
||||
- Added optional `pb.realtime.onDisconnect` hook function.
|
||||
_Note that the realtime client autoreconnect on its own and this hook is useful only for the cases where you want to apply a special behavior on server error or after closing the realtime connection._
|
||||
|
||||
|
||||
## 0.22.1
|
||||
|
||||
- Fixed old `pb.authStore.isAdmin`/`pb.authStore.isAuthRecord` and marked them as deprecated in favour of `pb.authStore.isSuperuser` ([#323](https://github.com/pocketbase/js-sdk/issues/323)).
|
||||
_Note that with PocketBase v0.23.0 superusers are converted to a system auth collection so you can always simply check the value of `pb.authStore.record?.collectionName`._
|
||||
|
||||
|
||||
## 0.22.0
|
||||
|
||||
**⚠️ This release introduces some breaking changes and works only with PocketBase v0.23.0+.**
|
||||
|
||||
- Added support for sending batch/transactional create/updated/delete/**upsert** requests with the new batch Web APIs.
|
||||
```js
|
||||
const batch = pb.createBatch();
|
||||
|
||||
batch.collection("example1").create({ ... });
|
||||
batch.collection("example2").update("RECORD_ID", { ... });
|
||||
batch.collection("example3").delete("RECORD_ID");
|
||||
batch.collection("example4").upsert({ ... });
|
||||
|
||||
const result = await batch.send();
|
||||
```
|
||||
|
||||
- Added support for authenticating with OTP (email code):
|
||||
```js
|
||||
const result = await pb.collection("users").requestOTP("test@example.com");
|
||||
|
||||
// ... show a modal for users to check their email and to enter the received code ...
|
||||
|
||||
await pb.collection("users").authWithOTP(result.otpId, "EMAIL_CODE");
|
||||
```
|
||||
|
||||
Note that PocketBase v0.23.0 comes also with Multi-factor authentication (MFA) support.
|
||||
When enabled from the dashboard, the first auth attempt will result in 401 response and a `mfaId` response,
|
||||
that will have to be submitted with the second auth request. For example:
|
||||
```js
|
||||
try {
|
||||
await pb.collection("users").authWithPassword("test@example.com", "1234567890");
|
||||
} catch (err) {
|
||||
const mfaId = err.response?.mfaId;
|
||||
if (!mfaId) {
|
||||
throw err; // not mfa -> rethrow
|
||||
}
|
||||
|
||||
// the user needs to authenticate again with another auth method, for example OTP
|
||||
const result = await pb.collection("users").requestOTP("test@example.com");
|
||||
// ... show a modal for users to check their email and to enter the received code ...
|
||||
await pb.collection("users").authWithOTP(result.otpId, "EMAIL_CODE", { "mfaId": mfaId });
|
||||
}
|
||||
```
|
||||
|
||||
- Added new `pb.collection("users").impersonate("RECORD_ID")` method for superusers.
|
||||
It authenticates with the specified record id and returns a new client with the impersonated auth state loaded in a memory store.
|
||||
```js
|
||||
// authenticate as superusers (with v0.23.0 admins is converted to a special system auth collection "_superusers"):
|
||||
await pb.collection("_superusers").authWithPassword("test@example.com", "1234567890");
|
||||
|
||||
// impersonate
|
||||
const impersonateClient = pb.collection("users").impersonate("USER_RECORD_ID", 3600 /* optional token duration in seconds */)
|
||||
|
||||
// log the impersonate token and user data
|
||||
console.log(impersonateClient.authStore.token);
|
||||
console.log(impersonateClient.authStore.record);
|
||||
|
||||
// send requests as the impersonated user
|
||||
impersonateClient.collection("example").getFullList();
|
||||
```
|
||||
|
||||
- Added new `pb.collections.getScaffolds()` method to retrieve a type indexed map with the collection models (base, auth, view) loaded with their defaults.
|
||||
|
||||
- Added new `pb.collections.truncate(idOrName)` to delete all records associated with the specified collection.
|
||||
|
||||
- Added the submitted fetch options as 3rd last argument in the `pb.afterSend` hook.
|
||||
|
||||
- Instead of replacing the entire `pb.authStore.record`, on auth record update we now only replace the available returned response record data ([pocketbase#5638](https://github.com/pocketbase/pocketbase/issues/5638)).
|
||||
|
||||
- ⚠️ Admins are converted to `_superusers` auth collection and there is no longer `AdminService` and `AdminModel` types.
|
||||
`pb.admins` is soft-deprecated and aliased to `pb.collection("_superusers")`.
|
||||
```js
|
||||
// before -> after
|
||||
pb.admins.* -> pb.collection("_superusers").*
|
||||
```
|
||||
|
||||
- ⚠️ `pb.authStore.model` is soft-deprecated and superseded by `pb.authStore.record`.
|
||||
|
||||
- ⚠️ Soft-deprecated the OAuth2 success auth `meta.avatarUrl` response field in favour of `meta.avatarURL` for consistency with the Go conventions.
|
||||
|
||||
- ⚠️ Changed `AuthMethodsList` inerface fields to accomodate the new auth methods and `listAuthMethods()` response.
|
||||
```
|
||||
{
|
||||
"mfa": {
|
||||
"duration": 100,
|
||||
"enabled": true
|
||||
},
|
||||
"otp": {
|
||||
"duration": 0,
|
||||
"enabled": false
|
||||
},
|
||||
"password": {
|
||||
"enabled": true,
|
||||
"identityFields": ["email", "username"]
|
||||
},
|
||||
"oauth2": {
|
||||
"enabled": true,
|
||||
"providers": [{"name": "gitlab", ...}, {"name": "google", ...}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- ⚠️ Require specifying collection id or name when sending test email because the email templates can be changed per collection.
|
||||
```js
|
||||
// old
|
||||
pb.settings.testEmail(email, "verification")
|
||||
|
||||
// new
|
||||
pb.settings.testEmail("users", email, "verification")
|
||||
```
|
||||
|
||||
- ⚠️ Soft-deprecated and aliased `*Url()` -> `*URL()` methods for consistency with other similar native JS APIs and the accepted Go conventions.
|
||||
_The old methods still works but you may get a console warning to replace them because they will be removed in the future._
|
||||
```js
|
||||
pb.baseUrl -> pb.baseURL
|
||||
pb.buildUrl() -> pb.buildURL()
|
||||
pb.files.getUrl() -> pb.files.getURL()
|
||||
pb.backups.getDownloadUrl() -> pb.backups.getDownloadURL()
|
||||
```
|
||||
|
||||
- ⚠️ Renamed `CollectionModel.schema` to `CollectionModel.fields`.
|
||||
|
||||
- ⚠️ Renamed type `SchemaField` to `CollectionField`.
|
||||
|
||||
|
||||
## 0.21.5
|
||||
|
||||
- Shallow copy the realtime subscribe `options` argument for consistency with the other methods ([#308](https://github.com/pocketbase/js-sdk/issues/308)).
|
||||
|
||||
|
||||
## 0.21.4
|
||||
|
||||
- Fixed the `requestKey` handling in `authWithOAuth2({...})` to allow manually cancelling the entire OAuth2 pending request flow using `pb.cancelRequest(requestKey)`.
|
||||
_Due to the [`window.close` caveats](https://developer.mozilla.org/en-US/docs/Web/API/Window/close) note that the OAuth2 popup window may still remain open depending on which stage of the OAuth2 flow the cancellation has been invoked._
|
||||
|
||||
|
||||
## 0.21.3
|
||||
|
||||
- Enforce temporary the `atob` polyfill for ReactNative until [Expo 51+ and React Native v0.74+ `atob` fix get released](https://github.com/reactwg/react-native-releases/issues/287).
|
||||
|
||||
|
||||
## 0.21.2
|
||||
|
||||
- Exported `HealthService` types ([#289](https://github.com/pocketbase/js-sdk/issues/289)).
|
||||
|
||||
|
||||
## 0.21.1
|
||||
|
||||
- Manually update the verified state of the current matching `AuthStore` model on successful "confirm-verification" call.
|
||||
|
||||
- Manually clear the current matching `AuthStore` on "confirm-email-change" call because previous tokens are always invalidated.
|
||||
|
||||
- Updated the `fetch` mock tests to check also the sent body params.
|
||||
|
||||
- Formatted the source and tests with prettier.
|
||||
|
||||
|
||||
## 0.21.0
|
||||
|
||||
**⚠️ This release works only with PocketBase v0.21.0+ due to changes of how the `multipart/form-data` body is handled.**
|
||||
|
||||
- Properly sent json body with `multipart/form-data` requests.
|
||||
_This should fix the edge cases mentioned in the v0.20.3 release._
|
||||
|
||||
- Gracefully handle OAuth2 redirect error with the `authWithOAuth2()` call.
|
||||
|
||||
|
||||
## 0.20.3
|
||||
|
||||
- Partial and temporary workaround for the auto `application/json` -> `multipart/form-data` request serialization of a `json` field when a `Blob`/`File` is found in the request body ([#274](https://github.com/pocketbase/js-sdk/issues/274)).
|
||||
|
||||
The "fix" is partial because there are still 2 edge cases that are not handled - when a `json` field value is empty array (eg. `[]`) or array of strings (eg. `["a","b"]`).
|
||||
The reason for this is because the SDK doesn't have information about the field types and doesn't know which field is a `json` or an arrayable `select`, `file` or `relation`, so it can't serialize it properly on its own as `FormData` string value.
|
||||
|
||||
If you are having troubles with persisting `json` values as part of a `multipart/form-data` request the easiest fix for now is to manually stringify the `json` field value:
|
||||
```js
|
||||
await pb.collection("example").create({
|
||||
// having a Blob/File as object value will convert the request to multipart/form-data
|
||||
"someFileField": new Blob([123]),
|
||||
"someJsonField": JSON.stringify(["a","b","c"]),
|
||||
})
|
||||
```
|
||||
|
||||
A proper fix for this will be implemented with PocketBase v0.21.0 where we'll have support for a special `@jsonPayload` multipart body key, which will allow us to submit mixed `multipart/form-data` content (_kindof similar to the `multipart/mixed` MIME_).
|
||||
|
||||
|
||||
## 0.20.2
|
||||
|
||||
- Throw 404 error for `getOne("")` when invoked with empty id ([#271](https://github.com/pocketbase/js-sdk/issues/271)).
|
||||
|
||||
- Added `@throw {ClientResponseError}` jsdoc annotation to the regular request methods ([#262](https://github.com/pocketbase/js-sdk/issues/262)).
|
||||
|
||||
|
||||
## 0.20.1
|
||||
|
||||
- Propagate the `PB_CONNECT` event to allow listening to the realtime connect/reconnect events.
|
||||
```js
|
||||
pb.realtime.subscribe("PB_CONNECT", (e) => {
|
||||
console.log(e.clientId);
|
||||
})
|
||||
```
|
||||
|
||||
## 0.20.0
|
||||
|
||||
- Added `expand`, `filter`, `fields`, custom query and headers parameters support for the realtime subscriptions.
|
||||
```js
|
||||
pb.collection("example").subscribe("*", (e) => {
|
||||
...
|
||||
}, { filter: "someField > 10" });
|
||||
```
|
||||
_This works only with PocketBase v0.20.0+._
|
||||
|
||||
- Changes to the logs service methods in relation to the logs generalization in PocketBase v0.20.0+:
|
||||
```js
|
||||
pb.logs.getRequestsList(...) -> pb.logs.getList(...)
|
||||
pb.logs.getRequest(...) -> pb.logs.getOne(...)
|
||||
pb.logs.getRequestsStats(...) -> pb.logs.getStats(...)
|
||||
```
|
||||
|
||||
- Added missing `SchemaField.presentable` field.
|
||||
|
||||
- Added new `AuthProviderInfo.displayName` string field.
|
||||
|
||||
- Added new `AuthMethodsList.onlyVerified` bool field.
|
||||
|
||||
|
||||
## 0.19.0
|
||||
|
||||
- Added `pb.filter(rawExpr, params?)` helper to construct a filter string with placeholder parameters populated from an object.
|
||||
|
||||
```js
|
||||
const record = await pb.collection("example").getList(1, 20, {
|
||||
// the same as: "title ~ 'te\\'st' && (totalA = 123 || totalB = 123)"
|
||||
filter: pb.filter("title ~ {:title} && (totalA = {:num} || totalB = {:num})", { title: "te'st", num: 123 })
|
||||
})
|
||||
```
|
||||
|
||||
The supported placeholder parameter values are:
|
||||
|
||||
- `string` (_single quotes will be autoescaped_)
|
||||
- `number`
|
||||
- `boolean`
|
||||
- `Date` object (_will be stringified into the format expected by PocketBase_)
|
||||
- `null`
|
||||
- anything else is converted to a string using `JSON.stringify()`
|
||||
|
||||
|
||||
## 0.18.3
|
||||
|
||||
- Added optional generic support for the `RecordService` ([#251](https://github.com/pocketbase/js-sdk/issues/251)).
|
||||
This should allow specifying a single TypeScript definition for the client, eg. using type assertion:
|
||||
```ts
|
||||
interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface TypedPocketBase extends PocketBase {
|
||||
collection(idOrName: string): RecordService // default fallback for any other collection
|
||||
collection(idOrName: 'tasks'): RecordService<Task>
|
||||
collection(idOrName: 'posts'): RecordService<Post>
|
||||
}
|
||||
|
||||
...
|
||||
|
||||
const pb = new PocketBase("http://127.0.0.1:8090") as TypedPocketBase;
|
||||
|
||||
// the same as pb.collection('tasks').getOne<Task>("RECORD_ID")
|
||||
await pb.collection('tasks').getOne("RECORD_ID") // -> results in Task
|
||||
|
||||
// the same as pb.collection('posts').getOne<Post>("RECORD_ID")
|
||||
await pb.collection('posts').getOne("RECORD_ID") // -> results in Post
|
||||
```
|
||||
|
||||
|
||||
## 0.18.2
|
||||
|
||||
- Added support for assigning a `Promise` as `AsyncAuthStore` initial value ([#249](https://github.com/pocketbase/js-sdk/issues/249)).
|
||||
|
||||
|
||||
## 0.18.1
|
||||
|
||||
- Fixed realtime subscriptions auto cancellation to use the proper `requestKey` param.
|
||||
|
||||
|
||||
## 0.18.0
|
||||
|
||||
- Added `pb.backups.upload(data)` action (_available with PocketBase v0.18.0_).
|
||||
|
||||
- Added _experimental_ `autoRefreshThreshold` option to auto refresh (or reauthenticate) the AuthStore when authenticated as admin.
|
||||
_This could be used as an alternative to fixed Admin API keys._
|
||||
```js
|
||||
await pb.admins.authWithPassword("test@example.com", "1234567890", {
|
||||
// This will trigger auto refresh or auto reauthentication in case
|
||||
// the token has expired or is going to expire in the next 30 minutes.
|
||||
autoRefreshThreshold: 30 * 60
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
## 0.17.3
|
||||
|
||||
- Loosen the type check when calling `pb.files.getUrl(user, filename)` to allow passing the `pb.authStore.model` without type assertion.
|
||||
|
||||
|
||||
## 0.17.2
|
||||
|
||||
- Fixed mulitple File/Blob array values not transformed properly to their FormData equivalent when an object syntax is used.
|
||||
|
||||
|
||||
## 0.17.1
|
||||
|
||||
- Fixed typo in the deprecation console.warn messages ([#235](https://github.com/pocketbase/js-sdk/pull/235); thanks @heloineto).
|
||||
|
||||
|
||||
## 0.17.0
|
||||
|
||||
- To simplify file uploads, we now allow sending the `multipart/form-data` request body also as plain object if at least one of the object props has `File` or `Blob` value.
|
||||
```js
|
||||
// the standard way to create multipart/form-data body
|
||||
const data = new FormData();
|
||||
data.set("title", "lorem ipsum...")
|
||||
data.set("document", new File(...))
|
||||
|
||||
// this is the same as above
|
||||
// (it will be converted behind the scenes to FormData)
|
||||
const data = {
|
||||
"title": "lorem ipsum...",
|
||||
"document": new File(...),
|
||||
};
|
||||
|
||||
await pb.collection("example").create(data);
|
||||
```
|
||||
|
||||
- Added new `pb.authStore.isAdmin` and `pb.authStore.isAuthRecord` helpers to check the type of the current auth state.
|
||||
|
||||
- The default `LocalAuthStore` now listen to the browser [storage event](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event),
|
||||
so that we can sync automatically the `pb.authStore` state between multiple tabs.
|
||||
|
||||
- Added new helper `AsyncAuthStore` class that can be used to integrate with any 3rd party async storage implementation (_usually this is needed when working with React Native_):
|
||||
```js
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import PocketBase, { AsyncAuthStore } from "pocketbase";
|
||||
|
||||
const store = new AsyncAuthStore({
|
||||
save: async (serialized) => AsyncStorage.setItem("pb_auth", serialized),
|
||||
initial: AsyncStorage.getItem("pb_auth"),
|
||||
});
|
||||
|
||||
const pb = new PocketBase("https://example.com", store)
|
||||
```
|
||||
|
||||
- `pb.files.getUrl()` now returns empty string in case an empty filename is passed.
|
||||
|
||||
- ⚠️ All API actions now return plain object (POJO) as response, aka. the custom class wrapping was removed and you no longer need to manually call `structuredClone(response)` when using with SSR frameworks.
|
||||
|
||||
This could be a breaking change if you use the below classes (_and respectively their helper methods like `$isNew`, `$load()`, etc._) since they were replaced with plain TS interfaces:
|
||||
```ts
|
||||
class BaseModel -> interface BaseModel
|
||||
class Admin -> interface AdminModel
|
||||
class Record -> interface RecordModel
|
||||
class LogRequest -> interface LogRequestModel
|
||||
class ExternalAuth -> interface ExternalAuthModel
|
||||
class Collection -> interface CollectionModel
|
||||
class SchemaField -> interface SchemaField
|
||||
class ListResult -> interface ListResult
|
||||
```
|
||||
|
||||
_Side-note:_ If you use somewhere in your code the `Record` and `Admin` classes to determine the type of your `pb.authStore.model`,
|
||||
you can safely replace it with the new `pb.authStore.isAdmin` and `pb.authStore.isAuthRecord` getters.
|
||||
|
||||
- ⚠️ Added support for per-request `fetch` options, including also specifying completely custom `fetch` implementation.
|
||||
|
||||
In addition to the default [`fetch` options](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options), the following configurable fields are supported:
|
||||
|
||||
```ts
|
||||
interface SendOptions extends RequestInit {
|
||||
// any other custom key will be merged with the query parameters
|
||||
// for backward compatibility and to minimize the verbosity
|
||||
[key: string]: any;
|
||||
|
||||
// optional custom fetch function to use for sending the request
|
||||
fetch?: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response>;
|
||||
|
||||
// custom headers to send with the requests
|
||||
headers?: { [key: string]: string };
|
||||
|
||||
// the body of the request (serialized automatically for json requests)
|
||||
body?: any;
|
||||
|
||||
// query params that will be appended to the request url
|
||||
query?: { [key: string]: any };
|
||||
|
||||
// the request identifier that can be used to cancel pending requests
|
||||
requestKey?: string|null;
|
||||
|
||||
// @deprecated use `requestKey:string` instead
|
||||
$cancelKey?: string;
|
||||
|
||||
// @deprecated use `requestKey:null` instead
|
||||
$autoCancel?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
For most users the above will not be a breaking change since there are available function overloads (_when possible_) to preserve the old behavior, but you can get a warning message in the console to update to the new format.
|
||||
For example:
|
||||
```js
|
||||
// OLD (should still work but with a warning in the console)
|
||||
await pb.collection("example").authRefresh({}, {
|
||||
"expand": "someRelField",
|
||||
})
|
||||
|
||||
// NEW
|
||||
await pb.collection("example").authRefresh({
|
||||
"expand": "someRelField",
|
||||
// send some additional header
|
||||
"headers": {
|
||||
"X-Custom-Header": "123",
|
||||
},
|
||||
"cache": "no-store" // also usually used by frameworks like Next.js
|
||||
})
|
||||
```
|
||||
|
||||
- Eagerly open the default OAuth2 signin popup in case no custom `urlCallback` is provided as a workaround for Safari.
|
||||
|
||||
- Internal refactoring (updated dev dependencies, refactored the tests to use Vitest instead of Mocha, etc.).
|
||||
|
||||
|
||||
## 0.16.0
|
||||
|
||||
- Added `skipTotal=1` query parameter by default for the `getFirstListItem()` and `getFullList()` requests.
|
||||
_Note that this have performance boost only with PocketBase v0.17+._
|
||||
|
||||
- Added optional `download=1` query parameter to force file urls with `Content-Disposition: attachment` (_supported with PocketBase v0.17+_).
|
||||
|
||||
|
||||
## 0.15.3
|
||||
|
||||
- Automatically resolve pending realtime connect `Promise`s in case `unsubscribe` is called before
|
||||
`subscribe` is being able to complete ([pocketbase#2897](https://github.com/pocketbase/pocketbase/discussions/2897#discussioncomment-6423818)).
|
||||
|
||||
|
||||
## 0.15.2
|
||||
|
||||
- Replaced `new URL(...)` with manual url parsing as it is not fully supported in React Native ([pocketbase#2484](https://github.com/pocketbase/pocketbase/discussions/2484#discussioncomment-6114540)).
|
||||
|
||||
- Fixed nested `ClientResponseError.originalError` wrapping and added `ClientResponseError` constructor tests.
|
||||
|
||||
|
||||
## 0.15.1
|
||||
|
||||
- Cancel any pending subscriptions submit requests on realtime disconnect ([#204](https://github.com/pocketbase/js-sdk/issues/204)).
|
||||
|
||||
|
||||
## 0.15.0
|
||||
|
||||
- Added `fields` to the optional query parameters for limiting the returned API fields (_available with PocketBase v0.16.0_).
|
||||
|
||||
- Added `pb.backups` service for the new PocketBase backup and restore APIs (_available with PocketBase v0.16.0_).
|
||||
|
||||
- Updated `pb.settings.testS3(filesystem)` to allow specifying a filesystem to test - `storage` or `backups` (_available with PocketBase v0.16.0_).
|
||||
|
||||
|
||||
## 0.14.4
|
||||
|
||||
- Removed the legacy aliased `BaseModel.isNew` getter since it conflicts with similarly named record fields ([pocketbase#2385](https://github.com/pocketbase/pocketbase/discussions/2385)).
|
||||
_This helper is mainly used in the Admin UI, but if you are also using it in your code you can replace it with the `$` prefixed version, aka. `BaseModel.$isNew`._
|
||||
|
||||
|
||||
## 0.14.3
|
||||
|
||||
- Added `OAuth2AuthConfig.query` prop to send optional query parameters with the `authWithOAuth2(config)` call.
|
||||
|
||||
|
||||
## 0.14.2
|
||||
|
||||
- Use `location.origin + location.pathname` instead of full `location.href` when constructing the browser absolute url to ignore any extra hash or query parameter passed to the base url.
|
||||
_This is a small addition to the earlier change from v0.14.1._
|
||||
|
||||
|
||||
## 0.14.1
|
||||
|
||||
- Use an absolute url when the SDK is initialized with a relative base path in a browser env to ensure that the generated OAuth2 redirect and file urls are absolute.
|
||||
|
||||
|
||||
## 0.14.0
|
||||
|
||||
- Added simplified `authWithOAuth2()` version without having to implement custom redirect, deeplink or even page reload:
|
||||
```js
|
||||
const authData = await pb.collection('users').authWithOAuth2({
|
||||
provider: 'google'
|
||||
})
|
||||
```
|
||||
|
||||
Works with PocketBase v0.15.0+.
|
||||
|
||||
This method initializes a one-off realtime subscription and will
|
||||
open a popup window with the OAuth2 vendor page to authenticate.
|
||||
Once the external OAuth2 sign-in/sign-up flow is completed, the popup
|
||||
window will be automatically closed and the OAuth2 data sent back
|
||||
to the user through the previously established realtime connection.
|
||||
|
||||
_Site-note_: when creating the OAuth2 app in the provider dashboard
|
||||
you have to configure `https://yourdomain.com/api/oauth2-redirect`
|
||||
as redirect URL.
|
||||
|
||||
_The "manual" code exchange flow is still supported as `authWithOAuth2Code(provider, code, codeVerifier, redirectUrl)`._
|
||||
|
||||
_For backward compatibility it is also available as soft-deprecated function overload of `authWithOAuth2(provider, code, codeVerifier, redirectUrl)`._
|
||||
|
||||
- Added new `pb.files` service:
|
||||
```js
|
||||
// Builds and returns an absolute record file url for the provided filename.
|
||||
🔓 pb.files.getUrl(record, filename, queryParams = {});
|
||||
|
||||
// Requests a new private file access token for the current auth model (admin or record).
|
||||
🔐 pb.files.getToken(queryParams = {});
|
||||
```
|
||||
_`pb.getFileUrl()` is soft-deprecated and acts as alias calling `pb.files.getUrl()` under the hood._
|
||||
|
||||
Works with PocketBase v0.15.0+.
|
||||
|
||||
|
||||
## 0.13.1
|
||||
|
||||
- Added option to specify a generic `send()` return type and defined `SendOptions` type ([#171](https://github.com/pocketbase/js-sdk/pull/171); thanks @iamelevich).
|
||||
|
||||
- Deprecated `SchemaField.unique` prop since its function is replaced by `Collection.indexes` in the upcoming PocketBase v0.14.0 release.
|
||||
|
||||
|
||||
## 0.13.0
|
||||
|
||||
- Aliased all `BaseModel` helpers with `$` equivalent to avoid conflicts with the dynamic record props ([#169](https://github.com/pocketbase/js-sdk/issues/169)).
|
||||
```js
|
||||
isNew -> $isNew
|
||||
load(data) -> $load(data)
|
||||
clone() -> $clone()
|
||||
export() -> $export()
|
||||
// ...
|
||||
```
|
||||
_For backward compatibility, the old helpers will still continue to work if the record doesn't have a conflicting field name._
|
||||
|
||||
- Updated `pb.beforeSend` and `pb.afterSend` signatures to allow returning and awaiting an optional `Promise` ([#166](https://github.com/pocketbase/js-sdk/pull/166); thanks @Bobby-McBobface).
|
||||
|
||||
- Added `Collection.indexes` field for the new collection indexes support in the upcoming PocketBase v0.14.0.
|
||||
|
||||
- Added `pb.settings.generateAppleClientSecret()` for sending a request to generate Apple OAuth2 client secret in the upcoming PocketBase v0.14.0.
|
||||
|
||||
|
||||
## 0.12.1
|
||||
|
||||
- Fixed request `multipart/form-data` body check to allow the React Native Android and iOS custom `FormData` implementation as valid `fetch` body ([#2002](https://github.com/pocketbase/pocketbase/discussions/2002)).
|
||||
|
||||
|
||||
## 0.12.0
|
||||
|
||||
- Changed the return type of `pb.beforeSend` hook to allow modifying the request url ([#1930](https://github.com/pocketbase/pocketbase/discussions/1930)).
|
||||
```js
|
||||
// old
|
||||
pb.beforeSend = function (url, options) {
|
||||
...
|
||||
return options;
|
||||
}
|
||||
|
||||
// new
|
||||
pb.beforeSend = function (url, options) {
|
||||
...
|
||||
return { url, options };
|
||||
}
|
||||
```
|
||||
The old return format is soft-deprecated and will still work, but you'll get a `console.warn` message to replace it.
|
||||
|
||||
|
||||
## 0.11.1
|
||||
|
||||
- Exported the services class definitions to allow being used as argument types ([#153](https://github.com/pocketbase/js-sdk/issues/153)).
|
||||
```js
|
||||
CrudService
|
||||
AdminService
|
||||
CollectionService
|
||||
LogService
|
||||
RealtimeService
|
||||
RecordService
|
||||
SettingsService
|
||||
```
|
||||
|
||||
## 0.11.0
|
||||
|
||||
- Aliased/soft-deprecated `ClientResponseError.data` in favor of `ClientResponseError.response` to avoid the stuttering when accessing the inner error response `data` key (aka. `err.data.data` now is `err.response.data`).
|
||||
The `ClientResponseError.data` will still work but it is recommend for new code to use the `response` key.
|
||||
|
||||
- Added `getFullList(queryParams = {})` overload since the default batch size in most cases doesn't need to change (it can be defined as query parameter).
|
||||
The old form `getFullList(batch = 200, queryParams = {})` will still work, but it is recommend for new code to use the shorter form.
|
||||
|
||||
|
||||
## 0.10.2
|
||||
|
||||
- Updated `getFileUrl()` to accept custom types as record argument.
|
||||
|
||||
|
||||
## 0.10.1
|
||||
|
||||
- Added check for the collection name before auto updating the `pb.authStore` state on auth record update/delete.
|
||||
|
||||
|
||||
## 0.10.0
|
||||
|
||||
- Added more helpful message for the `ECONNREFUSED ::1` localhost error (related to [#21](https://github.com/pocketbase/js-sdk/issues/21)).
|
||||
|
||||
- Preserved the "original" function and class names in the minified output for those who rely on `*.prototype.name`.
|
||||
|
||||
- Allowed sending the existing valid auth token with the `authWithPassword()` calls.
|
||||
|
||||
- Updated the Nuxt3 SSR examples to use the built-in `useCookie()` helper.
|
||||
|
||||
|
||||
## 0.9.1
|
||||
|
||||
- Normalized nested `expand` items to `Record|Array<Record>` instances.
|
||||
|
||||
|
||||
## 0.9.0
|
||||
|
||||
- Added `pb.health.check()` that checks the health status of the API service (_available in PocketBase v0.10.0_)
|
||||
|
||||
|
||||
## 0.8.4
|
||||
|
||||
- Added type declarations for the action query parameters ([#102](https://github.com/pocketbase/js-sdk/pull/102); thanks @sewera).
|
||||
```js
|
||||
BaseQueryParams
|
||||
ListQueryParams
|
||||
RecordQueryParams
|
||||
RecordListQueryParams
|
||||
LogStatsQueryParams
|
||||
FileQueryParams
|
||||
```
|
||||
|
||||
|
||||
## 0.8.3
|
||||
|
||||
- Renamed the declaration file extension from `.d.ts` to `.d.mts` to prevent type resolution issues ([#92](https://github.com/pocketbase/js-sdk/issues/92)).
|
||||
|
||||
|
||||
## 0.8.2
|
||||
|
||||
- Allowed catching the initial realtime connect error as part of the `subscribe()` Promise resolution.
|
||||
|
||||
- Reimplemented the default `EventSource` retry mechanism for better control and more consistent behavior across different browsers.
|
||||
|
||||
|
||||
## 0.8.1
|
||||
|
||||
This release contains only documentation fixes:
|
||||
|
||||
- Fixed code comment typos.
|
||||
|
||||
- Added note about loadFromCookie that you may need to call authRefresh to validate the loaded cookie state server-side.
|
||||
|
||||
- Updated the SSR examples to show the authRefresh call. _For the examples the authRefresh call is not required but it is there to remind users that it needs to be called if you want to do permission checks in a node env (eg. SSR) and rely on the `pb.authStore.isValid`._
|
||||
|
||||
|
||||
## 0.8.0
|
||||
|
||||
> ⚠️ Please note that this release works only with the new PocketBase v0.8+ API!
|
||||
>
|
||||
> See the breaking changes below for what has changed since v0.7.x.
|
||||
|
||||
#### Non breaking changes
|
||||
|
||||
- Added support for optional custom `Record` types using TypeScript generics, eg.
|
||||
`pb.collection('example').getList<Tasks>()`.
|
||||
|
||||
- Added new `pb.autoCancellation(bool)` method to globally enable or disable auto cancellation (`true` by default).
|
||||
|
||||
- Added new crud method `getFirstListItem(filter)` to fetch a single item by a list filter.
|
||||
|
||||
- You can now set additional account `createData` when authenticating with OAuth2.
|
||||
|
||||
- Added `AuthMethodsList.usernamePassword` return field (we now support combined username/email authentication; see below `authWithPassword`).
|
||||
|
||||
#### Breaking changes
|
||||
|
||||
- Changed the contstructor from `PocketBase(url, lang?, store?)` to `PocketBase(url, store?, lang?)` (aka. the `lang` option is now last).
|
||||
|
||||
- For easier and more conventional parsing, all DateTime strings now have `Z` as suffix, so that you can do directly `new Date('2022-01-01 01:02:03.456Z')`.
|
||||
|
||||
- Moved `pb.records.getFileUrl()` to `pb.getFileUrl()`.
|
||||
|
||||
- Moved all `pb.records.*` handlers under `pb.collection().*`:
|
||||
```
|
||||
pb.records.getFullList('example'); => pb.collection('example').getFullList();
|
||||
pb.records.getList('example'); => pb.collection('example').getList();
|
||||
pb.records.getOne('example', 'RECORD_ID'); => pb.collection('example').getOne('RECORD_ID');
|
||||
(no old equivalent) => pb.collection('example').getFirstListItem(filter);
|
||||
pb.records.create('example', {...}); => pb.collection('example').create({...});
|
||||
pb.records.update('example', 'RECORD_ID', {...}); => pb.collection('example').update('RECORD_ID', {...});
|
||||
pb.records.delete('example', 'RECORD_ID'); => pb.collection('example').delete('RECORD_ID');
|
||||
```
|
||||
|
||||
- The `pb.realtime` service has now a more general callback form so that it can be used with custom realtime handlers.
|
||||
Dedicated records specific subscribtions could be found under `pb.collection().*`:
|
||||
```
|
||||
pb.realtime.subscribe('example', callback) => pb.collection('example').subscribe("*", callback)
|
||||
pb.realtime.subscribe('example/RECORD_ID', callback) => pb.collection('example').subscribe('RECORD_ID', callback)
|
||||
pb.realtime.unsubscribe('example') => pb.collection('example').unsubscribe("*")
|
||||
pb.realtime.unsubscribe('example/RECORD_ID') => pb.collection('example').unsubscribe('RECORD_ID')
|
||||
(no old equivalent) => pb.collection('example').unsubscribe()
|
||||
```
|
||||
Additionally, `subscribe()` now return `UnsubscribeFunc` that could be used to unsubscribe only from a single subscription listener.
|
||||
|
||||
- Moved all `pb.users.*` handlers under `pb.collection().*`:
|
||||
```
|
||||
pb.users.listAuthMethods() => pb.collection('users').listAuthMethods()
|
||||
pb.users.authViaEmail(email, password) => pb.collection('users').authWithPassword(usernameOrEmail, password)
|
||||
pb.users.authViaOAuth2(provider, code, codeVerifier, redirectUrl) => pb.collection('users').authWithOAuth2(provider, code, codeVerifier, redirectUrl, createData = {})
|
||||
pb.users.refresh() => pb.collection('users').authRefresh()
|
||||
pb.users.requestPasswordReset(email) => pb.collection('users').requestPasswordReset(email)
|
||||
pb.users.confirmPasswordReset(resetToken, newPassword, newPasswordConfirm) => pb.collection('users').confirmPasswordReset(resetToken, newPassword, newPasswordConfirm)
|
||||
pb.users.requestVerification(email) => pb.collection('users').requestVerification(email)
|
||||
pb.users.confirmVerification(verificationToken) => pb.collection('users').confirmVerification(verificationToken)
|
||||
pb.users.requestEmailChange(newEmail) => pb.collection('users').requestEmailChange(newEmail)
|
||||
pb.users.confirmEmailChange(emailChangeToken, password) => pb.collection('users').confirmEmailChange(emailChangeToken, password)
|
||||
pb.users.listExternalAuths(recordId) => pb.collection('users').listExternalAuths(recordId)
|
||||
pb.users.unlinkExternalAuth(recordId, provider) => pb.collection('users').unlinkExternalAuth(recordId, provider)
|
||||
```
|
||||
|
||||
- Changes in `pb.admins` for consistency with the new auth handlers in `pb.collection().*`:
|
||||
```
|
||||
pb.admins.authViaEmail(email, password); => pb.admins.authWithPassword(email, password);
|
||||
pb.admins.refresh(); => pb.admins.authRefresh();
|
||||
```
|
||||
|
||||
- To prevent confusion with the auth method responses, the following methods now returns 204 with empty body (previously 200 with token and auth model):
|
||||
```js
|
||||
pb.admins.confirmPasswordReset(...): Promise<bool>
|
||||
pb.collection("users").confirmPasswordReset(...): Promise<bool>
|
||||
pb.collection("users").confirmVerification(...): Promise<bool>
|
||||
pb.collection("users").confirmEmailChange(...): Promise<bool>
|
||||
```
|
||||
|
||||
- Removed the `User` model because users are now regular records (aka. `Record`).
|
||||
**The old user fields `lastResetSentAt`, `lastVerificationSentAt` and `profile` are no longer available**
|
||||
(the `profile` fields are available under the `Record.*` property like any other fields).
|
||||
|
||||
- Renamed the special `Record` props:
|
||||
```
|
||||
@collectionId => collectionId
|
||||
@collectionName => collectionName
|
||||
@expand => expand
|
||||
```
|
||||
|
||||
- Since there is no longer `User` model, `pb.authStore.model` can now be of type `Record`, `Admin` or `null`.
|
||||
|
||||
- Removed `lastResetSentAt` from the `Admin` model.
|
||||
|
||||
- Replaced `ExternalAuth.userId` with 2 new `recordId` and `collectionId` props.
|
||||
|
||||
- Removed the deprecated uppercase service aliases:
|
||||
```
|
||||
client.Users => client.collection(*)
|
||||
client.Records => client.collection(*)
|
||||
client.AuthStore => client.authStore
|
||||
client.Realtime => client.realtime
|
||||
client.Admins => client.admins
|
||||
client.Collections => client.collections
|
||||
client.Logs => client.logs
|
||||
client.Settings => client.settings
|
||||
```
|
||||
17
script/node_modules/pocketbase/LICENSE.md
generated
vendored
Normal file
17
script/node_modules/pocketbase/LICENSE.md
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2022 - present, Gani Georgiev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
1091
script/node_modules/pocketbase/README.md
generated
vendored
Normal file
1091
script/node_modules/pocketbase/README.md
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1468
script/node_modules/pocketbase/dist/pocketbase.cjs.d.ts
generated
vendored
Normal file
1468
script/node_modules/pocketbase/dist/pocketbase.cjs.d.ts
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2
script/node_modules/pocketbase/dist/pocketbase.cjs.js
generated
vendored
Normal file
2
script/node_modules/pocketbase/dist/pocketbase.cjs.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
1
script/node_modules/pocketbase/dist/pocketbase.cjs.js.map
generated
vendored
Normal file
1
script/node_modules/pocketbase/dist/pocketbase.cjs.js.map
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
1583
script/node_modules/pocketbase/dist/pocketbase.es.d.mts
generated
vendored
Normal file
1583
script/node_modules/pocketbase/dist/pocketbase.es.d.mts
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1583
script/node_modules/pocketbase/dist/pocketbase.es.d.ts
generated
vendored
Normal file
1583
script/node_modules/pocketbase/dist/pocketbase.es.d.ts
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2
script/node_modules/pocketbase/dist/pocketbase.es.js
generated
vendored
Normal file
2
script/node_modules/pocketbase/dist/pocketbase.es.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
1
script/node_modules/pocketbase/dist/pocketbase.es.js.map
generated
vendored
Normal file
1
script/node_modules/pocketbase/dist/pocketbase.es.js.map
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
2
script/node_modules/pocketbase/dist/pocketbase.es.mjs
generated
vendored
Normal file
2
script/node_modules/pocketbase/dist/pocketbase.es.mjs
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
1
script/node_modules/pocketbase/dist/pocketbase.es.mjs.map
generated
vendored
Normal file
1
script/node_modules/pocketbase/dist/pocketbase.es.mjs.map
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
1468
script/node_modules/pocketbase/dist/pocketbase.iife.d.ts
generated
vendored
Normal file
1468
script/node_modules/pocketbase/dist/pocketbase.iife.d.ts
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2
script/node_modules/pocketbase/dist/pocketbase.iife.js
generated
vendored
Normal file
2
script/node_modules/pocketbase/dist/pocketbase.iife.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
1
script/node_modules/pocketbase/dist/pocketbase.iife.js.map
generated
vendored
Normal file
1
script/node_modules/pocketbase/dist/pocketbase.iife.js.map
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
1468
script/node_modules/pocketbase/dist/pocketbase.umd.d.ts
generated
vendored
Normal file
1468
script/node_modules/pocketbase/dist/pocketbase.umd.d.ts
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2
script/node_modules/pocketbase/dist/pocketbase.umd.js
generated
vendored
Normal file
2
script/node_modules/pocketbase/dist/pocketbase.umd.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
1
script/node_modules/pocketbase/dist/pocketbase.umd.js.map
generated
vendored
Normal file
1
script/node_modules/pocketbase/dist/pocketbase.umd.js.map
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
47
script/node_modules/pocketbase/package.json
generated
vendored
Normal file
47
script/node_modules/pocketbase/package.json
generated
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "0.26.8",
|
||||
"name": "pocketbase",
|
||||
"description": "PocketBase JavaScript SDK",
|
||||
"author": "Gani Georgiev",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/pocketbase/js-sdk.git"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/pocketbase.es.mjs",
|
||||
"./cjs": "./dist/pocketbase.cjs.js",
|
||||
"./umd": "./dist/pocketbase.umd.js"
|
||||
},
|
||||
"main": "./dist/pocketbase.es.mjs",
|
||||
"module": "./dist/pocketbase.es.mjs",
|
||||
"react-native": "./dist/pocketbase.es.js",
|
||||
"types": "./dist/pocketbase.es.d.mts",
|
||||
"keywords": [
|
||||
"pocketbase",
|
||||
"pocketbase-js",
|
||||
"js-sdk",
|
||||
"javascript-sdk",
|
||||
"pocketbase-sdk"
|
||||
],
|
||||
"prettier": {
|
||||
"tabWidth": 4,
|
||||
"printWidth": 90,
|
||||
"bracketSameLine": true
|
||||
},
|
||||
"scripts": {
|
||||
"format": "npx prettier ./src ./tests --write",
|
||||
"build": "rm -rf dist && rollup -c",
|
||||
"dev": "rollup -c -w",
|
||||
"test": "vitest",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-terser": "^0.4.3",
|
||||
"prettier": "3.2.4",
|
||||
"rollup": "^4.0.0",
|
||||
"rollup-plugin-ts": "^3.0.0",
|
||||
"typescript": "^5.1.6",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
22
script/package-lock.json
generated
Normal file
22
script/package-lock.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "script",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "script",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"pocketbase": "^0.26.8"
|
||||
}
|
||||
},
|
||||
"node_modules/pocketbase": {
|
||||
"version": "0.26.8",
|
||||
"resolved": "https://registry.npmmirror.com/pocketbase/-/pocketbase-0.26.8.tgz",
|
||||
"integrity": "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
script/package.json
Normal file
16
script/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "script",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "pocketbase.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"pocketbase": "^0.26.8"
|
||||
}
|
||||
}
|
||||
176
script/pocketbase.js
Normal file
176
script/pocketbase.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
// ================= 配置区 =================
|
||||
// 确保使用不带 /api 的根地址,并且端口是你后台管理的实际端口
|
||||
const PB_URL = 'http://new.blv-oa.com:8000';
|
||||
const ADMIN_EMAIL = '450481891@qq.com'; // 必须是超级管理员账号,不是普通用户!
|
||||
const ADMIN_PASSWORD = 'Momo123456';
|
||||
// ==========================================
|
||||
|
||||
const pb = new PocketBase(PB_URL);
|
||||
|
||||
const collections = [
|
||||
{
|
||||
name: 'tbl_system_dict',
|
||||
type: 'base',
|
||||
fields: [
|
||||
{ name: 'system_dict_id', type: 'text', required: true },
|
||||
{ name: 'dict_name', type: 'text' },
|
||||
{ name: 'dict_word_enum', type: 'text' },
|
||||
{ name: 'dict_word_description', type: 'text' },
|
||||
{ name: 'dict_word_is_enabled', type: 'bool' },
|
||||
{ name: 'dict_word_sort_order', type: 'number' },
|
||||
{ name: 'dict_word_parent_id', type: 'text' },
|
||||
{ name: 'dict_word_remark', type: 'text' }
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_system_dict_id ON tbl_system_dict (system_dict_id)',
|
||||
'CREATE INDEX idx_dict_word_parent_id ON tbl_system_dict (dict_word_parent_id)'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'tbl_company',
|
||||
type: 'base',
|
||||
fields: [
|
||||
{ name: 'company_id', type: 'text', required: true },
|
||||
{ name: 'company_name', type: 'text' },
|
||||
{ name: 'company_type', type: 'text' },
|
||||
{ name: 'company_entity', type: 'text' },
|
||||
{ name: 'company_usci', type: 'text' },
|
||||
{ name: 'company_nationality', type: 'text' },
|
||||
{ name: 'company_province', type: 'text' },
|
||||
{ name: 'company_city', type: 'text' },
|
||||
{ name: 'company_postalcode', type: 'text' },
|
||||
{ name: 'company_add', type: 'text' },
|
||||
{ name: 'company_status', type: 'text' },
|
||||
{ name: 'company_level', type: 'text' },
|
||||
{ name: 'company_remark', type: 'text' }
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_company_id ON tbl_company (company_id)',
|
||||
'CREATE INDEX idx_company_usci ON tbl_company (company_usci)'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'tbl_user_groups',
|
||||
type: 'base',
|
||||
fields: [
|
||||
{ name: 'usergroups_id', type: 'text', required: true },
|
||||
{ name: 'usergroups_name', type: 'text' },
|
||||
{ name: 'usergroups_level', type: 'number' },
|
||||
{ name: 'usergroups_remark', type: 'text' }
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_usergroups_id ON tbl_user_groups (usergroups_id)'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'tbl_users',
|
||||
type: 'base',
|
||||
fields: [
|
||||
{ name: 'users_id', type: 'text', required: true },
|
||||
{ name: 'users_name', type: 'text' },
|
||||
{ name: 'users_idtype', type: 'text' },
|
||||
{ name: 'users_id_number', type: 'text' },
|
||||
{ name: 'users_phone', type: 'text' },
|
||||
{ name: 'users_wx_openid', 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: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||
{ name: 'usergroups_id', type: 'text' }
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_users_id ON tbl_users (users_id)',
|
||||
'CREATE UNIQUE INDEX idx_users_phone ON tbl_users (users_phone)',
|
||||
'CREATE UNIQUE INDEX idx_users_wx_openid ON tbl_users (users_wx_openid)',
|
||||
'CREATE INDEX idx_users_company_id ON tbl_users (company_id)',
|
||||
'CREATE INDEX idx_users_usergroups_id ON tbl_users (usergroups_id)',
|
||||
'CREATE INDEX idx_users_parent_id ON tbl_users (users_parent_id)'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
console.log('🔄 正在登录管理员账号...');
|
||||
await pb.admins.authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
console.log('✅ 登录成功!开始初始化表结构与索引...\n');
|
||||
|
||||
for (const collectionData of collections) {
|
||||
await createOrUpdateCollection(collectionData);
|
||||
}
|
||||
|
||||
await verifyCollections(collections);
|
||||
console.log('\n🎉 所有表结构及索引初始化并校验完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化失败:', error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等创建/更新集合
|
||||
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;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCollections(targetCollections) {
|
||||
console.log('\n🔍 开始校验表结构与索引...');
|
||||
|
||||
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 (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