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

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

View File

@@ -2,7 +2,7 @@ const dotenv = require('dotenv')
dotenv.config()
const requiredEnv = ['WECHAT_APPID', 'WECHAT_SECRET', 'JWT_SECRET']
const requiredEnv = []
for (const key of requiredEnv) {
if (!process.env[key]) {
@@ -21,8 +21,4 @@ module.exports = {
|| `${process.env.APP_PROTOCOL || 'http'}://${process.env.APP_DOMAIN || 'localhost'}${process.env.PORT ? `:${process.env.PORT}` : ''}`,
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

@@ -1,36 +0,0 @@
const asyncHandler = require('../utils/asyncHandler')
const { success } = require('../utils/response')
const {
validateLoginBody,
validateProfileEditBody,
} = require('../middlewares/validateWechatAuth')
const userService = require('../services/userService')
const login = asyncHandler(async (req, res) => {
const payload = validateLoginBody(req.body)
const data = await userService.authenticateWechatUser(payload)
const messageMap = {
register_success: '注册成功',
login_success: '登录成功',
}
return success(res, messageMap[data.status] || '登录成功', data)
})
const updateProfile = asyncHandler(async (req, res) => {
const payload = validateProfileEditBody(req.body)
const data = await userService.updateWechatUserProfile(req.usersWxOpenid, payload)
return success(res, '信息更新成功', data)
})
const refreshToken = asyncHandler(async (req, res) => {
const data = await userService.refreshWechatToken(req.usersWxOpenid)
return success(res, '刷新成功', data)
})
module.exports = {
updateProfile,
login,
refreshToken,
}

View File

@@ -29,7 +29,6 @@ if (require.main === module) {
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`)
})
}

View File

@@ -1,22 +0,0 @@
const AppError = require('../utils/appError')
const requestCache = new Map()
const WINDOW_MS = 5000
module.exports = function duplicateGuard(req, res, next) {
const key = `${req.ip}:${req.originalUrl}:${JSON.stringify(req.body || {})}`
const now = Date.now()
const lastTime = requestCache.get(key)
if (lastTime && now - lastTime < WINDOW_MS) {
return next(new AppError('请求过于频繁,请稍后重试', 429))
}
requestCache.set(key, now)
setTimeout(() => {
requestCache.delete(key)
}, WINDOW_MS)
next()
}

View File

@@ -1,20 +0,0 @@
const jwt = require('jsonwebtoken')
const env = require('../config/env')
const AppError = require('../utils/appError')
module.exports = function jwtAuth(req, res, next) {
const authHeader = req.headers.authorization || ''
const [scheme, token] = authHeader.split(' ')
if (scheme !== 'Bearer' || !token) {
return next(new AppError('未提供有效的认证令牌', 401))
}
try {
const decoded = jwt.verify(token, env.jwtSecret)
req.user = decoded
next()
} catch {
next(new AppError('认证令牌无效或已过期', 401))
}
}

View File

@@ -1,15 +0,0 @@
const AppError = require('../utils/appError')
module.exports = function requireJsonBody(req, res, next) {
const methods = ['POST', 'PUT', 'PATCH']
if (!methods.includes(req.method)) {
return next()
}
if (!req.is('application/json')) {
return next(new AppError('请求体必须为 application/json', 415))
}
next()
}

View File

@@ -1,12 +0,0 @@
const AppError = require('../utils/appError')
module.exports = function requireWechatOpenid(req, res, next) {
const usersWxOpenid = req.headers['users_wx_openid']
if (!usersWxOpenid) {
return next(new AppError('请求头缺少 users_wx_openid', 401))
}
req.usersWxOpenid = usersWxOpenid
next()
}

View File

@@ -1,35 +0,0 @@
const AppError = require('../utils/appError')
const { sanitizePayload } = require('../utils/sanitize')
function validateLoginBody(body = {}) {
const payload = sanitizePayload(body)
if (!payload.users_wx_code) {
throw new AppError('users_wx_code 为必填项', 400)
}
return payload
}
function validateProfileEditBody(body = {}) {
const payload = sanitizePayload(body)
if (!payload.users_name) {
throw new AppError('users_name 为必填项', 400)
}
if (!payload.users_phone_code) {
throw new AppError('users_phone_code 为必填项', 400)
}
if (!payload.users_picture) {
throw new AppError('users_picture 为必填项', 400)
}
return payload
}
module.exports = {
validateLoginBody,
validateProfileEditBody,
}

View File

@@ -1,31 +0,0 @@
const jwt = require('jsonwebtoken')
const env = require('../config/env')
const AppError = require('../utils/appError')
const requireWechatOpenid = require('./requireWechatOpenid')
module.exports = function wechatHeadersAuth(req, res, next) {
requireWechatOpenid(req, res, (openidError) => {
if (openidError) {
return next(openidError)
}
const usersWxOpenid = req.usersWxOpenid
const authHeader = req.headers.authorization || ''
const [scheme, token] = authHeader.split(' ')
if (scheme !== 'Bearer' || !token) {
return next(new AppError('请求头缺少 Authorization', 401))
}
try {
const decoded = jwt.verify(token, env.jwtSecret)
if (decoded.users_wx_openid && decoded.users_wx_openid !== usersWxOpenid) {
return next(new AppError('请求头中的 users_wx_openid 与令牌不匹配', 401))
}
req.user = decoded
next()
} catch {
next(new AppError('认证令牌无效或已过期', 401))
}
})
}

View File

@@ -1,19 +1,8 @@
const express = require('express')
const { success } = require('../utils/response')
const { getBuildTimestamp } = require('../utils/buildInfo')
const wechatRoutes = require('./wechatRoutes')
const router = express.Router()
function respondHelloWorld(req, res) {
return success(res, '请求成功', {
message: 'Hello, World!',
timestamp: new Date().toISOString(),
status: 'success',
build_time: getBuildTimestamp(),
})
}
function respondHealth(req, res) {
return success(res, '服务运行正常', {
status: 'healthy',
@@ -21,10 +10,6 @@ function respondHealth(req, res) {
})
}
router.post('/test-helloworld', respondHelloWorld)
router.post('/health', respondHealth)
router.use('/wechat', wechatRoutes)
module.exports = router

View File

@@ -1,14 +0,0 @@
const express = require('express')
const duplicateGuard = require('../middlewares/duplicateGuard')
const requireJsonBody = require('../middlewares/requireJsonBody')
const requireWechatOpenid = require('../middlewares/requireWechatOpenid')
const wechatHeadersAuth = require('../middlewares/wechatHeadersAuth')
const controller = require('../controllers/wechatController')
const router = express.Router()
router.post('/login', requireJsonBody, duplicateGuard, controller.login)
router.post('/profile', requireJsonBody, wechatHeadersAuth, duplicateGuard, controller.updateProfile)
router.post('/refresh-token', requireWechatOpenid, controller.refreshToken)
module.exports = router

View File

@@ -1,12 +0,0 @@
const jwt = require('jsonwebtoken')
const env = require('../config/env')
function signAccessToken(payload) {
return jwt.sign(payload, env.jwtSecret, {
expiresIn: env.jwtExpiresIn,
})
}
module.exports = {
signAccessToken,
}

View File

@@ -1,208 +0,0 @@
const crypto = require('crypto')
const AppError = require('../utils/appError')
const logger = require('../utils/logger')
const wechatService = require('./wechatService')
const jwtService = require('./jwtService')
const pocketbaseService = require('./pocketbaseService')
const userMutationLocks = new Map()
const GUEST_USER_TYPE = '游客'
const REGISTERED_USER_TYPE = '注册用户'
function buildUserId() {
const now = new Date()
const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
const suffix = crypto.randomInt(1000, 9999)
return `U${date}${suffix}`
}
async function enrichUser(user) {
const company = await pocketbaseService.getCompanyByCompanyId(user.company_id)
return {
pb_id: user.id,
users_id: user.users_id,
users_type: user.users_type || GUEST_USER_TYPE,
users_name: user.users_name,
users_phone: user.users_phone,
users_phone_masked: maskPhone(user.users_phone),
users_picture: user.users_picture,
users_wx_openid: user.users_wx_openid,
company_id: user.company_id || '',
company,
created: user.created,
updated: user.updated,
raw: user,
}
}
async function findUserByOpenid(usersWxOpenid) {
const users = await pocketbaseService.listUsersByFilter(`users_wx_openid = "${usersWxOpenid}"`)
return users[0] || null
}
function maskPhone(phone = '') {
if (!phone || phone.length < 7) return ''
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
}
function isInfoComplete(user = {}) {
return Boolean(user.users_name && user.users_phone && user.users_picture)
}
function isAllProfileFieldsEmpty(user = {}) {
return !user.users_name && !user.users_phone && !user.users_picture
}
async function withUserLock(lockKey, handler) {
const previous = userMutationLocks.get(lockKey) || Promise.resolve()
let release
const current = new Promise((resolve) => {
release = resolve
})
userMutationLocks.set(lockKey, previous.then(() => current))
await previous
try {
return await handler()
} finally {
release()
if (userMutationLocks.get(lockKey) === current) {
userMutationLocks.delete(lockKey)
}
}
}
async function authenticateWechatUser(payload) {
const openid = await wechatService.getWxOpenId(payload.users_wx_code)
return withUserLock(`auth:${openid}`, async () => {
const existing = await findUserByOpenid(openid)
if (existing) {
logger.warn('微信注册命中已存在账号', {
users_wx_openid: openid,
users_type: existing.users_type || GUEST_USER_TYPE,
})
const user = await enrichUser(existing)
const token = jwtService.signAccessToken({
users_id: user.users_id,
users_wx_openid: user.users_wx_openid,
})
return {
status: 'login_success',
is_info_complete: isInfoComplete(existing),
token,
user,
}
}
const created = await pocketbaseService.createUser({
users_id: buildUserId(),
users_wx_openid: openid,
users_type: GUEST_USER_TYPE,
})
const user = await enrichUser(created)
const token = jwtService.signAccessToken({
users_id: user.users_id,
users_wx_openid: user.users_wx_openid,
})
logger.info('微信用户注册成功', {
users_id: user.users_id,
users_phone: user.users_phone,
users_type: user.users_type,
})
return {
status: 'register_success',
is_info_complete: false,
token,
user,
}
})
}
async function updateWechatUserProfile(usersWxOpenid, payload) {
return withUserLock(`profile:${usersWxOpenid}`, async () => {
const currentUser = await findUserByOpenid(usersWxOpenid)
if (!currentUser) {
throw new AppError('未找到待编辑的用户', 404)
}
const usersPhone = await wechatService.getWxPhoneNumber(payload.users_phone_code)
if (usersPhone && usersPhone !== currentUser.users_phone) {
const samePhoneUsers = await pocketbaseService.listUsersByFilter(`users_phone = "${usersPhone}"`)
const phoneUsedByOther = samePhoneUsers.some((item) => item.id !== currentUser.id)
if (phoneUsedByOther) {
throw new AppError('手机号已被注册', 400)
}
}
const shouldPromoteUserType =
isAllProfileFieldsEmpty(currentUser)
&& payload.users_name
&& usersPhone
&& payload.users_picture
&& (currentUser.users_type === GUEST_USER_TYPE || !currentUser.users_type)
const updatePayload = {
users_name: payload.users_name,
users_phone: usersPhone,
users_picture: payload.users_picture,
}
if (shouldPromoteUserType) {
updatePayload.users_type = REGISTERED_USER_TYPE
}
const updated = await pocketbaseService.updateUser(currentUser.id, updatePayload)
const user = await enrichUser(updated)
logger.info('微信用户资料更新成功', {
users_id: user.users_id,
users_phone: user.users_phone,
users_type_before: currentUser.users_type || GUEST_USER_TYPE,
users_type_after: user.users_type,
users_type_promoted: shouldPromoteUserType,
})
return {
status: 'update_success',
user,
}
})
}
async function refreshWechatToken(usersWxOpenid) {
const userRecord = await findUserByOpenid(usersWxOpenid)
if (!userRecord) {
throw new AppError('未注册用户', 404)
}
const token = jwtService.signAccessToken({
users_id: userRecord.users_id,
users_wx_openid: userRecord.users_wx_openid,
})
logger.info('微信用户刷新令牌成功', {
users_id: userRecord.users_id,
users_wx_openid: userRecord.users_wx_openid,
})
return {
token,
}
}
module.exports = {
authenticateWechatUser,
updateWechatUserProfile,
refreshWechatToken,
}

View File

@@ -1,117 +0,0 @@
const axios = require('axios')
const env = require('../config/env')
const AppError = require('../utils/appError')
let accessTokenCache = {
token: '',
expiresAt: 0,
}
async function getWxOpenId(code) {
if (!env.wechatAppId || !env.wechatSecret) {
throw new AppError('微信小程序配置缺失', 500)
}
try {
const url = 'https://api.weixin.qq.com/sns/jscode2session'
const response = await axios.get(url, {
params: {
appid: env.wechatAppId,
secret: env.wechatSecret,
js_code: code,
grant_type: 'authorization_code',
},
timeout: 10000,
})
const data = response.data || {}
if (data.openid) {
return data.openid
}
if (data.errcode || data.errmsg) {
throw new AppError(`获取OpenID失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
wechat: data,
})
}
throw new AppError('获取OpenID失败: 响应中未包含openid', 502)
} catch (error) {
if (error instanceof AppError) throw error
throw new AppError(`获取微信OpenID时发生错误: ${error.message}`, 502)
}
}
async function getWechatAccessToken() {
if (accessTokenCache.token && Date.now() < accessTokenCache.expiresAt) {
return accessTokenCache.token
}
try {
const response = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
params: {
grant_type: 'client_credential',
appid: env.wechatAppId,
secret: env.wechatSecret,
},
timeout: 10000,
})
const data = response.data || {}
if (!data.access_token) {
throw new AppError(`获取微信 access_token 失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
wechat: data,
})
}
accessTokenCache = {
token: data.access_token,
expiresAt: Date.now() + Math.max((data.expires_in || 7200) - 300, 60) * 1000,
}
return accessTokenCache.token
} catch (error) {
if (error instanceof AppError) throw error
throw new AppError(`获取微信 access_token 时发生错误: ${error.message}`, 502)
}
}
async function getWxPhoneNumber(phoneCode) {
const accessToken = await getWechatAccessToken()
try {
const response = await axios.post(
`https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`,
{
code: phoneCode,
},
{
headers: {
'Content-Type': 'application/json',
},
timeout: 10000,
}
)
const data = response.data || {}
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber
if (phone) {
return phone
}
throw new AppError(`获取微信手机号失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
wechat: data,
})
} catch (error) {
if (error instanceof AppError) throw error
throw new AppError(`获取微信手机号时发生错误: ${error.message}`, 502)
}
}
module.exports = {
getWxOpenId,
getWxPhoneNumber,
}

View File

@@ -1,10 +0,0 @@
class AppError extends Error {
constructor(message, statusCode = 500, details = {}) {
super(message)
this.name = 'AppError'
this.statusCode = statusCode
this.details = details
}
}
module.exports = AppError

View File

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

View File

@@ -1,30 +0,0 @@
const fs = require('fs')
const path = require('path')
function getBuildInfoFilePath() {
return path.resolve(__dirname, '..', '..', 'build-info.json')
}
function getBuildTimestamp() {
if (process.env.BUILD_TIME) {
return process.env.BUILD_TIME
}
const filePath = getBuildInfoFilePath()
if (!fs.existsSync(filePath)) {
return null
}
try {
const content = fs.readFileSync(filePath, 'utf8')
const payload = JSON.parse(content)
return payload.buildTime || null
} catch {
return null
}
}
module.exports = {
getBuildTimestamp,
}

View File

@@ -1,17 +0,0 @@
function sanitizeString(value) {
if (typeof value !== 'string') return ''
return value.replace(/[<>\\]/g, '').trim()
}
function sanitizePayload(payload = {}) {
return Object.keys(payload).reduce((acc, key) => {
const value = payload[key]
acc[key] = typeof value === 'string' ? sanitizeString(value) : value
return acc
}, {})
}
module.exports = {
sanitizeString,
sanitizePayload,
}