feat: 完善微信认证功能,新增用户资料更新与token刷新接口
- 新增 userService.js,包含用户认证、资料更新、token 刷新等功能 - 新增 wechatService.js,处理微信API交互,获取openid和手机号 - 新增 appError.js,封装应用错误处理 - 新增 logger.js,提供日志记录功能 - 新增 response.js,统一成功响应格式 - 新增 sanitize.js,提供输入数据清洗功能 - 更新 OpenAPI 文档,描述新增接口及请求响应格式 - 更新 PocketBase 数据库结构,调整用户表字段及索引策略 - 增强错误处理机制,确保错误信息可观测性 - 更新变更记录文档,详细记录本次变更内容
This commit is contained in:
@@ -1,12 +0,0 @@
|
||||
const jwt = require('jsonwebtoken')
|
||||
const env = require('../config/env')
|
||||
|
||||
function signAccessToken(payload) {
|
||||
return jwt.sign(payload, env.jwtSecret, {
|
||||
expiresIn: env.jwtExpiresIn,
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
signAccessToken,
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
const crypto = require('crypto')
|
||||
const AppError = require('../utils/appError')
|
||||
const logger = require('../utils/logger')
|
||||
const wechatService = require('./wechatService')
|
||||
const jwtService = require('./jwtService')
|
||||
const pocketbaseService = require('./pocketbaseService')
|
||||
|
||||
const userMutationLocks = new Map()
|
||||
const GUEST_USER_TYPE = '游客'
|
||||
const REGISTERED_USER_TYPE = '注册用户'
|
||||
|
||||
function buildUserId() {
|
||||
const now = new Date()
|
||||
const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
|
||||
const suffix = crypto.randomInt(1000, 9999)
|
||||
return `U${date}${suffix}`
|
||||
}
|
||||
|
||||
async function enrichUser(user) {
|
||||
const company = await pocketbaseService.getCompanyByCompanyId(user.company_id)
|
||||
return {
|
||||
pb_id: user.id,
|
||||
users_id: user.users_id,
|
||||
users_type: user.users_type || GUEST_USER_TYPE,
|
||||
users_name: user.users_name,
|
||||
users_phone: user.users_phone,
|
||||
users_phone_masked: maskPhone(user.users_phone),
|
||||
users_picture: user.users_picture,
|
||||
users_wx_openid: user.users_wx_openid,
|
||||
company_id: user.company_id || '',
|
||||
company,
|
||||
created: user.created,
|
||||
updated: user.updated,
|
||||
raw: user,
|
||||
}
|
||||
}
|
||||
|
||||
async function findUserByOpenid(usersWxOpenid) {
|
||||
const users = await pocketbaseService.listUsersByFilter(`users_wx_openid = "${usersWxOpenid}"`)
|
||||
return users[0] || null
|
||||
}
|
||||
|
||||
function maskPhone(phone = '') {
|
||||
if (!phone || phone.length < 7) return ''
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
||||
}
|
||||
|
||||
function isInfoComplete(user = {}) {
|
||||
return Boolean(user.users_name && user.users_phone && user.users_picture)
|
||||
}
|
||||
|
||||
function isAllProfileFieldsEmpty(user = {}) {
|
||||
return !user.users_name && !user.users_phone && !user.users_picture
|
||||
}
|
||||
|
||||
async function withUserLock(lockKey, handler) {
|
||||
const previous = userMutationLocks.get(lockKey) || Promise.resolve()
|
||||
let release
|
||||
const current = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
|
||||
userMutationLocks.set(lockKey, previous.then(() => current))
|
||||
|
||||
await previous
|
||||
|
||||
try {
|
||||
return await handler()
|
||||
} finally {
|
||||
release()
|
||||
if (userMutationLocks.get(lockKey) === current) {
|
||||
userMutationLocks.delete(lockKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateWechatUser(payload) {
|
||||
const openid = await wechatService.getWxOpenId(payload.users_wx_code)
|
||||
return withUserLock(`auth:${openid}`, async () => {
|
||||
const existing = await findUserByOpenid(openid)
|
||||
|
||||
if (existing) {
|
||||
logger.warn('微信注册命中已存在账号', {
|
||||
users_wx_openid: openid,
|
||||
users_type: existing.users_type || GUEST_USER_TYPE,
|
||||
})
|
||||
|
||||
const user = await enrichUser(existing)
|
||||
const token = jwtService.signAccessToken({
|
||||
users_id: user.users_id,
|
||||
users_wx_openid: user.users_wx_openid,
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'login_success',
|
||||
is_info_complete: isInfoComplete(existing),
|
||||
token,
|
||||
user,
|
||||
}
|
||||
}
|
||||
|
||||
const created = await pocketbaseService.createUser({
|
||||
users_id: buildUserId(),
|
||||
users_wx_openid: openid,
|
||||
users_type: GUEST_USER_TYPE,
|
||||
})
|
||||
|
||||
const user = await enrichUser(created)
|
||||
const token = jwtService.signAccessToken({
|
||||
users_id: user.users_id,
|
||||
users_wx_openid: user.users_wx_openid,
|
||||
})
|
||||
|
||||
logger.info('微信用户注册成功', {
|
||||
users_id: user.users_id,
|
||||
users_phone: user.users_phone,
|
||||
users_type: user.users_type,
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'register_success',
|
||||
is_info_complete: false,
|
||||
token,
|
||||
user,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function updateWechatUserProfile(usersWxOpenid, payload) {
|
||||
return withUserLock(`profile:${usersWxOpenid}`, async () => {
|
||||
const currentUser = await findUserByOpenid(usersWxOpenid)
|
||||
|
||||
if (!currentUser) {
|
||||
throw new AppError('未找到待编辑的用户', 404)
|
||||
}
|
||||
|
||||
const usersPhone = await wechatService.getWxPhoneNumber(payload.users_phone_code)
|
||||
|
||||
if (usersPhone && usersPhone !== currentUser.users_phone) {
|
||||
const samePhoneUsers = await pocketbaseService.listUsersByFilter(`users_phone = "${usersPhone}"`)
|
||||
const phoneUsedByOther = samePhoneUsers.some((item) => item.id !== currentUser.id)
|
||||
if (phoneUsedByOther) {
|
||||
throw new AppError('手机号已被注册', 400)
|
||||
}
|
||||
}
|
||||
|
||||
const shouldPromoteUserType =
|
||||
isAllProfileFieldsEmpty(currentUser)
|
||||
&& payload.users_name
|
||||
&& usersPhone
|
||||
&& payload.users_picture
|
||||
&& (currentUser.users_type === GUEST_USER_TYPE || !currentUser.users_type)
|
||||
|
||||
const updatePayload = {
|
||||
users_name: payload.users_name,
|
||||
users_phone: usersPhone,
|
||||
users_picture: payload.users_picture,
|
||||
}
|
||||
|
||||
if (shouldPromoteUserType) {
|
||||
updatePayload.users_type = REGISTERED_USER_TYPE
|
||||
}
|
||||
|
||||
const updated = await pocketbaseService.updateUser(currentUser.id, updatePayload)
|
||||
const user = await enrichUser(updated)
|
||||
|
||||
logger.info('微信用户资料更新成功', {
|
||||
users_id: user.users_id,
|
||||
users_phone: user.users_phone,
|
||||
users_type_before: currentUser.users_type || GUEST_USER_TYPE,
|
||||
users_type_after: user.users_type,
|
||||
users_type_promoted: shouldPromoteUserType,
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'update_success',
|
||||
user,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshWechatToken(usersWxOpenid) {
|
||||
const userRecord = await findUserByOpenid(usersWxOpenid)
|
||||
|
||||
if (!userRecord) {
|
||||
throw new AppError('未注册用户', 404)
|
||||
}
|
||||
|
||||
const token = jwtService.signAccessToken({
|
||||
users_id: userRecord.users_id,
|
||||
users_wx_openid: userRecord.users_wx_openid,
|
||||
})
|
||||
|
||||
logger.info('微信用户刷新令牌成功', {
|
||||
users_id: userRecord.users_id,
|
||||
users_wx_openid: userRecord.users_wx_openid,
|
||||
})
|
||||
|
||||
return {
|
||||
token,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticateWechatUser,
|
||||
updateWechatUserProfile,
|
||||
refreshWechatToken,
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
const axios = require('axios')
|
||||
const env = require('../config/env')
|
||||
const AppError = require('../utils/appError')
|
||||
|
||||
let accessTokenCache = {
|
||||
token: '',
|
||||
expiresAt: 0,
|
||||
}
|
||||
|
||||
async function getWxOpenId(code) {
|
||||
if (!env.wechatAppId || !env.wechatSecret) {
|
||||
throw new AppError('微信小程序配置缺失', 500)
|
||||
}
|
||||
|
||||
try {
|
||||
const url = 'https://api.weixin.qq.com/sns/jscode2session'
|
||||
const response = await axios.get(url, {
|
||||
params: {
|
||||
appid: env.wechatAppId,
|
||||
secret: env.wechatSecret,
|
||||
js_code: code,
|
||||
grant_type: 'authorization_code',
|
||||
},
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
const data = response.data || {}
|
||||
|
||||
if (data.openid) {
|
||||
return data.openid
|
||||
}
|
||||
|
||||
if (data.errcode || data.errmsg) {
|
||||
throw new AppError(`获取OpenID失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
|
||||
wechat: data,
|
||||
})
|
||||
}
|
||||
|
||||
throw new AppError('获取OpenID失败: 响应中未包含openid', 502)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error
|
||||
throw new AppError(`获取微信OpenID时发生错误: ${error.message}`, 502)
|
||||
}
|
||||
}
|
||||
|
||||
async function getWechatAccessToken() {
|
||||
if (accessTokenCache.token && Date.now() < accessTokenCache.expiresAt) {
|
||||
return accessTokenCache.token
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
|
||||
params: {
|
||||
grant_type: 'client_credential',
|
||||
appid: env.wechatAppId,
|
||||
secret: env.wechatSecret,
|
||||
},
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
const data = response.data || {}
|
||||
|
||||
if (!data.access_token) {
|
||||
throw new AppError(`获取微信 access_token 失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
|
||||
wechat: data,
|
||||
})
|
||||
}
|
||||
|
||||
accessTokenCache = {
|
||||
token: data.access_token,
|
||||
expiresAt: Date.now() + Math.max((data.expires_in || 7200) - 300, 60) * 1000,
|
||||
}
|
||||
|
||||
return accessTokenCache.token
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error
|
||||
throw new AppError(`获取微信 access_token 时发生错误: ${error.message}`, 502)
|
||||
}
|
||||
}
|
||||
|
||||
async function getWxPhoneNumber(phoneCode) {
|
||||
const accessToken = await getWechatAccessToken()
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`,
|
||||
{
|
||||
code: phoneCode,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10000,
|
||||
}
|
||||
)
|
||||
|
||||
const data = response.data || {}
|
||||
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber
|
||||
|
||||
if (phone) {
|
||||
return phone
|
||||
}
|
||||
|
||||
throw new AppError(`获取微信手机号失败: ${data.errcode || ''} ${data.errmsg || ''}`.trim(), 502, {
|
||||
wechat: data,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error
|
||||
throw new AppError(`获取微信手机号时发生错误: ${error.message}`, 502)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getWxOpenId,
|
||||
getWxPhoneNumber,
|
||||
}
|
||||
Reference in New Issue
Block a user