使用Node.js开发微信第三方平台后台

微信第三方平台的配置中,我们已经创建了第三方平台,并提交了审核;这一篇我们要去搭建微信第三方平台的后台,并部署、全网发布。

只有全网发布的第三方平台才可以被小程序、公众号授权

我们要实现的效果就是用户只要扫我们的授权码,就可以授权给我们

用户扫码后出现的授权界面

用户小程序后台显示已授权给第三方平台

代码仓库链接

开始开发配置

在权限集里添加需要的权限,我把小程序、公众号与服务号权限集除消息管理的都选了

然后进入开发资料填写页面

验证票据(component_verify_ticket),在第三方平台创建审核通过后,微信服务器会向其 "授权事件接收URL" 每隔 10 分钟以 POST 的方式推送 component_verify_ticket

就是说当我们填写了开发资料,微信就开始推了验证票据

那么我们为什么需要这个component_verify_ticket呢

我们以上一篇微信第三方平台的配置提到的上传代码并生成体验版这个为例,

发布代码需要authorizer_access_token,authorizer_access_token获取需要component_access_token

获取component_access_token需要component_verify_ticket

流程就是这样

markdown 复制代码
微信服务器(每10分钟)───► component_verify_ticket
       ↓
使用 component_verify_ticket + appid + appsecret
       ↓
获得 component_access_token
       ↓
使用 component_access_token + authorizer_appid + authorizer_refresh_token
       ↓
获得 authorizer_access_token
       ↓
使用 authorizer_access_token 完成代码上传、发布等操作

就是说component_verify_ticket 是微信第三方平台的核心凭证,是微信安全机制的关键环节,主要用于:

  1. 验证平台身份:证明你确实是已注册的第三方平台,而非伪造者。
  2. 获取接口调用凭据:通过它才能获取 component_access_token(调用其他接口的必须凭证)。
  3. 定时更新:微信每 10 分钟推送一次新的 ticket,必须实时接收并存储。
如何获取 component_verify_ticket

根据component_verify_ticket文档,微信会推,推过来解密就可以看到了

我们先新建一个文件夹wx-third-party, 然后在这个文件夹里执行npm init -y 先初始化一个项目,安装我们要用到的依赖

sql 复制代码
pnpm add express body-parser dotenv xml2js
pnpm add -D typescript @types/express @types/node @types/xml2js @types/body-parser

新建tsconfig.json,TypeScript编译器(tsc)的配置文件,决定了TypeScript 如何编译为 JavaScript

json 复制代码
{
  "compilerOptions": {
    "target": "ES6", // 编译目标为 ES6
    "module": "commonjs", // 使用 CommonJS 模块规范
    "lib": ["ES6", "DOM"], // 包含的标准库
    "outDir": "./dist", // 编译输出目录
    "rootDir": "./src", // 源代码根目录
    "strict": true, // 启用所有严格类型检查选项
    "esModuleInterop": true, // 支持 CommonJS 和 ES6 模块互操作性
    "skipLibCheck": true, // 跳过类型声明文件检查
    "forceConsistentCasingInFileNames": true, // 强制文件名大小写一致
    "moduleResolution": "node", // 使用 Node.js 模块解析策略
    "resolveJsonModule": true, // 允许导入 JSON 文件
    "baseUrl": ".", // 基础路径
    "paths": {
      // 路径映射
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*.ts"], // 包含的文件
  "exclude": ["node_modules", "dist"] // 排除的文件
}

新建.env文件

.env 复制代码
WECHAT_COMPONENT_APPID=第三方平台的ID
WECHAT_COMPONENT_SECRET=第三方平台的SECRET
WECHAT_TOKEN=开发资料里配置的消息校验Token
WECHAT_ENCODING_AES_KEY=开发资料里配置的消息加解密Key
NODE_ENV=development
PORT=3000

新建src文件夹,新建src/app.ts

ts 复制代码
import express, { Request, Response, NextFunction } from 'express'
import bodyParser from 'body-parser'
import path from 'path'
import { EventController } from './controllers/event.controller'


import dotenv from 'dotenv'
dotenv.config() // 一定要放最上面,提前加载环境变量
const app = express()

// 中间件
app.use(
  bodyParser.text({
    type: ['application/xml', 'text/xml'], // 微信的请求是 Content-Type: text/xml
  })
)

// 微信验证服务器有效性(GET)
app.get('/wechat/callback', EventController.handleServerVerify)

// 微信事件推送处理(POST)
app.post('/wechat/callback', EventController.handleEvent)

// 错误处理
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error('服务器错误:', err)
  res.status(500).send('Internal Server Error')
})

// 启动服务器
app.listen(port, () => {
  console.log(`服务器运行在 http://localhost:${port}`)
})

然后新建controllers/event.controller.ts

ts 复制代码
import { Request, Response, NextFunction } from 'express'
import { CryptoService } from '../services/crypto.service'
import { TicketService } from '../services/ticket.service'
import { TokenService } from '../services/token.service'
import { xmlToJson } from '../utils/xml.util'

export class EventController {
  // 处理微信服务器验证
  static async handleServerVerify(
    req: Request,
    res: Response,
    next: NextFunction
  ) {
    try {
      const { signature, timestamp, nonce, echostr } = req.query

      // 验证签名
      const isValid =
        CryptoService.generateSignature(
          timestamp as string,
          nonce as string
        ) === signature

      if (isValid) {
        res.send(echostr)
      } else {
        res.status(403).send('Invalid signature')
      }
    } catch (error) {
      next(error)
    }
  }

  // 处理微信事件
  static async handleEvent(req: Request, res: Response, next: NextFunction) {
    try {
      console.log('Content-Type:', req.headers['content-type']) // 应该是 application/xml
      console.log('Raw Body:', req.body) // 打印原始请求体
      const { signature, timestamp, nonce, msg_signature } = req.query
      // 1. 解析 XML
      const parsedXml = await xmlToJson(req.body)
      console.log('解析后的 XML:', parsedXml)
      const encryptedMsg = parsedXml.xml.Encrypt
      if (!encryptedMsg) {
        return res.status(400).send('Missing Encrypt field')
      }
      console.log('加密消息:', encryptedMsg)
      console.log('查询参数:', req.query)

      // 2. 验证签名
      const signatureCheck = CryptoService.generateSignature(
        timestamp as string,
        nonce as string,
        encryptedMsg
      )
      if (signatureCheck !== msg_signature) {
        return res.status(403).send('Invalid signature')
      }
      console.log('签名验证通过', signatureCheck, msg_signature)

      // 3. 解密
      const decryptedXml = CryptoService.decryptMessage(encryptedMsg)
      const event = await xmlToJson(decryptedXml)
      const infoType = event.xml.InfoType
      console.log('解密后的事件:', event)

      console.log('事件类型:', infoType)
      // 处理不同类型的事件
      switch (infoType) {
        case 'component_verify_ticket':
          const ticket = event.xml.ComponentVerifyTicket
          TicketService.updateTicket(event.xml.AppId, ticket)
          await TokenService.getComponentAccessToken()
          break

        case 'authorized':
          // 处理授权成功事件
          break

        case 'updateauthorized':
          // 处理更新授权事件
          break

        case 'unauthorized':
          // 处理取消授权事件
          break
      }

      res.send('success')
    } catch (error) {
      next(error)
    }
  }
}
ts 复制代码
// 微信验证服务器有效性(GET)
app.get('/wechat/callback', EventController.handleServerVerify)

微信平台在首次配置"消息与事件接收URL"时用来验证URL是否有效的 GET请求处理器,这一步是微信平台的自动验证机制。

  • token(在微信后台填写的消息校验Token) + timestamp + nonce,用特定算法生成一个签名。
  • 把生成的签名与微信发来的 signature 做对比。
  • 如果相同,你就要原样返回微信发来的 echostr 字符串,微信才会认为"你验证通过了"。
ts 复制代码
// 微信事件推送处理(POST)
app.post('/wechat/callback', EventController.handleEvent)

正式推送微信服务器消息(如 component_verify_ticket)→ 发的是 POST 请求,请求会同时携带URL 查询参数 (如 signaturetimestamp 等)和请求体(XML 格式的加密数据)

  • 请求查询参数用来验证请求的真实性
  • 请求体用来解密出ticket

第三方平台需对推送消息进行签名验证,确认该消息确实来自微信服务器,防止伪造

请求参数

js 复制代码
{
    signature: '5dc9c8c9780da112e6f1c0356a1a0df1ccc7a46e',
    timestamp: '1753947294',
    nonce: '1081650160',
    encrypt_type: 'aes',
    msg_signature: 'fcd2e97dd80d37dc2c73ccb4432ca1afca8e44db'
}

请求体

xml 复制代码
<xml>
    <AppId><![CDATA[wxe6a7126d9dea1d76]]></AppId>
    <Encrypt><![CDATA[XhLdPySIoB8mKiEvSIDB4Jy8jLJhomUlv1eoYU0OcqPvSIFiliVWnKYXlfztwt/GZ81ar9dPXg0emm6iJecDJTuhzN2sDNH/OkkCrk4RCO3cswPskqbs2mMYRDZaMKwXcMOcSfrWUtz7U9A9HbORswoq5VZrzLbdpGDPUeh9OWMi6bOHsgNlCebbuM1PHw7ztY8wTdoO1lZ8yy//4VnPVupLvE/psP/FFVqAfLWnMH1uHjPcvqpXMmGdPjJ3VWBOoNDmCkodHpFU2/gWkRjPWM78GbLIDrcdXBH7BNp6je0MG/uP8yPFGk9I0lX36KPotFtpeCukufW4OPbMuBkMa7ii8QABnzou+rWxKzeacINdAvda4k40iVCE3bP6sXCmJ5SFZozT0jV67NLAWpSb+wzkcmsthCxGrpatS/ibOuCyH7GTWF9HsdzsWXEu48KvFvt2kgFZYuyEluGY+qlUmg==]]></Encrypt>
</xml>

那如果那些想攻击伪造这些参数和请求体是否可以呢,答案是不可以的。

因为我们加密与微信推送的参数msg_signature比较,需要用到我们配置的消息校验Token, 而那些想攻击的不知道我们的Token, 校验就不会通过,我们就可以判断不是微信推送的

只知道 token :签名验证能过,但解密失败
只知道 key :可能能解密,但签名验证通不过,甚至无法进入解密逻辑

所以除非消息校验Token、消息加解密Key都泄密,否则是无法伪造微信推送Ticket的

伪造发送ticket请求 是通不过签名验证的

解密后的请求体是这样的

xml 复制代码
xml: {
    AppId: 'wxe6a7126d9dea1d76',
    CreateTime: '1754017681',
    InfoType: 'component_verify_ticket',
    ComponentVerifyTicket: 'ticket@@@baCPYdEYHzLLOnjm0S1sP7SqkHrG4DPFkGEngWeQQMKrED1Qg2jgc40ASQQ8Ae4etluoyD6eA74kk8m_CdZfnA'
}

看下用到的ticket.service.ts,获取到了ticket就存入数据库

typescript 复制代码
import { saveVerifyTicket } from '../config/redis'

export class TicketService {
  // 使用 Redis存储 ticket
  private static componentVerifyTicket: string = ''

  // 更新 ticket,同时保存到 Redis
  static async updateTicket(appId: string, ticket: string) {
    this.componentVerifyTicket = ticket

    if (appId) {
      await saveVerifyTicket(appId, ticket)
      console.log(`ticket 已保存到 Redis,appId: ${appId}`)
    }
  }

  // 获取 ticket
  static getTicket() {
    return this.componentVerifyTicket
  }
}

看下redis.ts,存入redis,从redis获取ticket方法

ts 复制代码
import Redis from 'ioredis'
console.log('Redis URL:', process.env.REDIS_URL)

if (!process.env.REDIS_URL) {
  throw new Error('REDIS_URL is not defined')
}

export const redis = new Redis(process.env.REDIS_URL || '')
export async function saveVerifyTicket(appId: string, ticket: string) {
  const key = `wechat:verify_ticket:${appId}`
  await redis.set(key, ticket, 'EX', 600) // 设置过期时间为 10 分钟
}

export async function getVerifyTicket(appId: string): Promise<string | null> {
  const key = `wechat:verify_ticket:${appId}`
  return await redis.get(key)
}

看下token.service.ts, 里面我们根据获取到的ticket, 拿到了componentAccessToken

typescript 复制代码
import axios from 'axios'
import { WECHAT_CONFIG } from '../config/wechat'
import { TicketService } from './ticket.service'
import { redis } from '../config/redis'

export class TokenService {
  private static redisKey = `wechat:component_access_token:${WECHAT_CONFIG.appId}`
  private static componentAccessToken: string | null = null
  private static expiresAt = 0

  static async getComponentAccessToken(): Promise<string> {
    // 1. 优先使用内存缓存
    if (this.isTokenValid()) {
      return this.componentAccessToken!
    }

    // 2. Redis 缓存
    const cached = await redis.get(this.redisKey)
    if (cached) {
      // 注意:Redis 无法提供 expiresAt,我们只能假设有效
      this.componentAccessToken = cached
      this.expiresAt = Date.now() + 110 * 60 * 1000 // 假设 Redis 设置了 2 小时有效期,我们保守估计只用 110 分钟
      return cached
    }

    // 3. 拉取新 token
    return await this.refreshComponentAccessToken()
  }

  private static async refreshComponentAccessToken(): Promise<string> {
    const ticket = await TicketService.getTicket()
    if (!ticket) {
      throw new Error('没有可用的 component_verify_ticket')
    }

    const res = await axios.post(
      `${WECHAT_CONFIG.apiBaseUrl}/component/api_component_token`,
      {
        component_appid: WECHAT_CONFIG.appId,
        component_appsecret: WECHAT_CONFIG.appSecret,
        component_verify_ticket: ticket,
      }
    )

    const { component_access_token, expires_in } = res.data
    console.log(
      '获取到新的 component_access_token:',
      component_access_token,
      'expires_in:',
      expires_in
    )
    // 校验 expires_in 是否为有效数字
    const ttl = parseInt(String(expires_in), 10)
    if (isNaN(ttl) || ttl <= 300) {
      console.error('微信 API 返回的 expires_in 值无效:', res.data)
      throw new Error(`无效的 expires_in 值: ${expires_in}`)
    }

    // 更新 Redis
    await redis.set(
      this.redisKey,
      component_access_token,
      'EX',
      expires_in - 300
    )

    // 同步更新内存
    this.componentAccessToken = component_access_token
    this.expiresAt = Date.now() + (expires_in - 300) * 1000

    console.log('已刷新并保存到 Redis 的 component_access_token')
    return component_access_token
  }

  private static isTokenValid(): boolean {
    return !!this.componentAccessToken && this.expiresAt > Date.now()
  }
}

这样我们就完成了授权事件接收配置的功能,

授权发起功能

在app.ts里加上路由, 发起授权的流程

  1. 用户访问我们/auth这个路由,触发请求pre_auth_code
  2. 我们根据前面拿到的component_access_token获取pre_auth_code
  3. 根据pre_auth_code,和我们写的授权回调url,就可以拼接一个授权链接
  4. 用户点击授权链接,就会跳到微信的授权页
  5. 当用户在微信授权页上完成授权后,微信会回调我们指定的 redirect_uri,并附带 auth_codeexpires_in
  6. 我们继续请求使用授权码获取授权信息,这里可以把获取到的授权信息存入redis,
  7. 返回授权成功
ts 复制代码
// 1. 配置模板引擎为 EJS
app.set('view engine', 'ejs')
// 2. 告诉 Express 模板文件的目录(相对于 src)
app.set('views', path.join(__dirname, 'views'))
// 授权发起页域名
app.get('/auth', AuthController.handleAuth)
// 微信授权完跳回你这
app.get('/auth/callback', AuthController.handleAuthCallback)

看下对应的AuthController

ts 复制代码
import { Request, Response, NextFunction } from 'express'
import { TokenService } from '../services/token.service'
export class AuthController {
  static async handleAuth(req: Request, res: Response) {
    try {
      const componentAppId = process.env.WECHAT_COMPONENT_APPID!
      const preAuthCode = await TokenService.getPreAuthCode() // 从微信获取
      console.log('获取到的 pre_auth_code:', preAuthCode)
      const redirectUri = encodeURIComponent(
        'https://wx-third-party.onrender.com/auth/callback'
      ) // 微信授权完跳回你这

      // 这个mp.weixin.qq.com 是要跳转的

      const authUrl = `https://mp.weixin.qq.com/cgi-bin/componentloginpage?component_appid=${componentAppId}&pre_auth_code=${preAuthCode}&redirect_uri=${redirectUri}`

      // 渲染 EJS 模板,传递 authUrl 变量
      res.render('auth-guide', { authUrl })
      //   res.redirect(url)
    } catch (error) {
      console.error('授权跳转失败', error)
      res.status(500).send('授权跳转失败')
    }
  }

  static async handleAuthCallback(req: Request, res: Response) {
    const { auth_code, expires_in } = req.query

    if (!auth_code) {
      return res.status(400).send('缺少 auth_code')
    }

    try {
      // 用 auth_code 换取授权信息
      const result = await TokenService.getAuthorizerAccessToken(
        auth_code as string
      )

      console.log('授权成功,授权数据:', result)

      res.send('授权成功')
    } catch (err) {
      console.error('授权失败:', err)
      res.status(500).send('授权失败')
    }
  }
}

用户访问/auth时候,我们先给了一个授权的界面,因为微信要确认我们授权发起URL, 所以要用户点击授权,这样微信就能判断了,我们用了EJS去渲染这个界面

更新授权推送的解密XML

xml 复制代码
{
    AppId: 'wxe6a7126d9dea1d76',
    CreateTime: '1754019427',
    InfoType: 'updateauthorized',
    AuthorizerAppid: 'wx6c95ebed50ec1261',
    AuthorizationCode: 'queryauthcode@@@VBV7H1cayg2fr8wm5xSkfXpzTG2dbBqnJQZo-B9vB75OSAae8juIrBQn-xRnWEtSOpNADGhBElRZUawW8Ma-cw',
    AuthorizationCodeExpiredTime: '1754023027',
    PreAuthCode: 'preauthcode@@@13oEHRpp5-5P6QZoZsylFa1Fv2m-vgVjUbV6Jv_JyCzTGbTAdzvJWGR80tPPCfViGMrhy0BT4AbAfEkGXbuRsQ',
    RedirectUri: '',
    BusinessData: ''
}

获取到的授权信息,报错长这样

css 复制代码
{
    errcode: 47001,
    errmsg: 'data format error rid: 688c3666-361c0c4c-60875129'
}
配置消息与事件接收配置

这个是用于代授权的公众号或小程序的接收平台推送的消息与事件,所以URL有${appid}

app.ts 复制代码
// 消息与事件接收配置
app.post('/:appid/callback', EventController.handleAppCallback)

在先前的event.controller.ts加上,验证签名,解密出来给授权的公众号或小程序推送的信息

csharp 复制代码
  // 支持多 appid 的事件处理(:appid/callback)
  static async handleAppCallback(
    req: Request,
    res: Response,
    next: NextFunction
  ) {
    try {
      const { appid } = req.params
      const { signature, timestamp, nonce, msg_signature, encrypt_type } =
        req.query

      const xmlData = req.body

      const isValid =
        CryptoService.generateSignature(
          timestamp as string,
          nonce as string
        ) === signature

      if (!isValid) return res.status(401).send('Invalid signature')

      let decryptedMsg = xmlData

      if (encrypt_type === 'aes') {
        const parsed = await xmlToJson(xmlData)
        const encrypted = parsed.xml.Encrypt
        if (!encrypted) return res.status(400).send('Missing encrypted message')

        // 验证消息签名
        const check = CryptoService.generateSignature(
          timestamp as string,
          nonce as string,
          encrypted
        )
        if (check !== msg_signature) {
          return res.status(403).send('Invalid message signature')
        }

        decryptedMsg = CryptoService.decryptMessage(encrypted)
      }

      const result = await xmlToJson(decryptedMsg)
      const msg = result.xml

      console.log(`[${appid}] Received message:`, msg)

      res.send('success')
    } catch (error) {
      next(error)
    }
  }
全网发布

我们将代码部署,测试后看这些功能是否正常,主要是能否成功发起授权,走一遍流程。我部署在render, 免费服务器,而且还有免费的redis,提交代码到github就可以自动部署,可以看日志。

这样就可以被公众号、小程序授权了,代码仓库链接开头已经放了

最后小结 一些加密方法 redis配置没有贴出来讲,都在代码里,感兴趣可以自己看下。主要是讲怎么自己搭建微信第三方平台后台,测试这些接口,也算是成功挑战了一下自己,作为一个在职前端,写出了一个功能正常的后端服务。

相关推荐
奕辰杰3 小时前
关于npm前端项目编译时栈溢出 Maximum call stack size exceeded的处理方案
前端·npm·node.js
换日线°8 小时前
css 不错的按钮动画
前端·css·微信小程序
qq_427506089 小时前
JavaScript和小程序写水印的方法示例
前端·算法·微信小程序
yzzzzzzzzzzzzzzzzz13 小时前
node.js之Koa框架
node.js
Java陈序员14 小时前
轻松设计 Logo!一款 Pornhub 风格的 Logo 在线生成器!
vue.js·node.js·vite
猫头_17 小时前
uni-app 转微信小程序 · 避坑与实战全记录
前端·微信小程序·uni-app
JavaDog程序狗1 天前
【软件环境】Windows安装NVM
前端·node.js
自学也学好编程1 天前
【工具】NVM完全指南:Node.js版本管理工具的安装与使用详解
node.js
Moment1 天前
调试代码,是每个前端逃不过的必修课 😏😏😏
前端·javascript·node.js