feat: 实现微信小程序后端接口与用户认证系统

新增微信登录/注册合一接口、资料完善接口和token刷新接口
重构用户服务层,支持自动维护用户类型和资料完整度
引入JWT认证中间件和请求验证中间件
更新文档与测试用例,支持dist构建部署
This commit is contained in:
2026-03-20 18:32:58 +08:00
parent 6d713c22ed
commit 72e974672e
89 changed files with 18233 additions and 365 deletions

View File

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

View File

@@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

32
back-end/dist/package.json vendored Normal file
View 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
View 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
View 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',
}

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

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

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

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

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

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

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

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

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

View 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

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

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

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

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

View 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
View 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
View 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
View 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
View 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: '^_' }],
},
},
]

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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()

View File

@@ -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: 服务端异常

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

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

View File

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

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

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

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

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

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

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

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

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

View 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

View 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

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

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

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

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

View 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

View File

@@ -0,0 +1,5 @@
module.exports = function asyncHandler(fn) {
return function wrappedHandler(req, res, next) {
Promise.resolve(fn(req, res, next)).catch(next)
}
}

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

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

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

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

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