微信小程序页面迁移校验之前 P5任务处理之前

This commit is contained in:
Neo
2026-03-09 01:19:21 +08:00
parent 263bf96035
commit 6e20987d2f
1112 changed files with 153824 additions and 219694 deletions

View File

@@ -222,9 +222,9 @@ pause
|------|------|
| 已完成 | 跳板机已配置好(用户确认) |
| 已完成 | Tailscale 内网已配置DB_HOST=100.64.0.4 |
| | 确认 Nginx 将 `api.langlangzhuoqiu.cn` 反代到 Tailscale IP:8000正式 |
| | 确认 Nginx 将测试环境反代到 Tailscale IP:8001如需区分域名 |
| | 确认 SSL 证书有效且自动续期 |
| 完成 20260224 | 确认 Nginx 将 `api.langlangzhuoqiu.cn` 反代到 Tailscale IP:8000正式 |
| 完成 20260224 | 确认 Nginx 将测试环境反代到 Tailscale IP:8001如需区分域名 |
| 完成 20260224 | 确认 SSL 证书有效且自动续期 |
> 跳板机本身已配好,这里只需确认反代规则指向了正确的后端端口。
> 如果测试和正式共用 `api.langlangzhuoqiu.cn`,则体验版和正式版会打到同一个后端。
@@ -282,9 +282,9 @@ Get-ChildItem $backupDir -Filter "*.dump" | Where-Object { $_.LastWriteTime -lt
| 状态 | 项目 |
|------|------|
| 已完成 | 后端接口 `GET/POST /api/wx/callback` 已实现(`wx_callback.py` |
| | 在 `apps/backend/.env.local` 中配置 `WX_CALLBACK_TOKEN` |
| | 服务器上部署最新代码并重启后端 |
| | 微信后台填写消息推送配置并提交验证 |
| 已完成 | 在 `apps/backend/.env.local` 中配置 `WX_CALLBACK_TOKEN` |
| 已完成 | 服务器上部署最新代码并重启后端 |
| 未完成 | 微信后台填写消息推送配置并提交验证 |
> 消息推送配置必须在服务器后端已启动、跳板机反代已就绪之后才能操作。
> 微信会向你的 URL 发 GET 请求验签,后端必须在线才能通过。
@@ -304,29 +304,42 @@ Get-ChildItem $backupDir -Filter "*.dump" | Where-Object { $_.LastWriteTime -lt
- Nginx 反代未指向正确端口
- Token 两边不一致
**需要支持加密模式,见增补文档路径下的文档**
### 2.3 隐私协议 / 用户隐私保护指引
| 状态 | 项目 |
|------|------|
| | 在微信后台填写用户隐私保护指引 |
| | 小程序端实现隐私授权弹窗组件 |
操作路径:微信后台 - 设置 - 基本设置 - 服务内容声明 - 用户隐私保护指引
操作路径:微信后台 → 账号设置 服务内容声明 用户隐私保护指引 → 去完善
(也可在提交审核时填写,入口:管理 → 版本管理 → 提交代码审核)
需要声明你收集了哪些用户信息
- 微信昵称、头像(如果用到 `wx.getUserProfile`
- 用户标识openid`wx.login` 必然涉及)
- 设备信息(如果用到 `wx.getSystemInfo`
根据你实际使用的隐私接口声明,对照官方列表
https://developers.weixin.qq.com/miniprogram/dev/framework/user-privacy/miniprogram-intro.html
> 2023 年 9 月起微信强制要求。不填写的话,调用 `wx.login` 等隐私相关 API 会直接报错。
> 即使你当前只用了 `wx.login`,也需要声明"用户标识"这一项。
当前项目可能涉及的声明项:
- 收集你的手机号(如果用了 `<button open-type="getPhoneNumber">`
- 收集你的昵称、头像(如果用了 `<button open-type="chooseAvatar">``<input type="nickname">`
> 注意:`wx.getUserProfile` 和 `wx.getUserInfo` 已被微信回收,不再可用。
> `wx.login` 和 `wx.getSystemInfo` 不在隐私接口列表中,无需声明。
小程序端适配(必须):
- 2023 年 10 月 17 日起微信强制启用隐私保护功能
- 需使用 `wx.getPrivacySetting` / `wx.onNeedPrivacyAuthorization` 处理隐私授权
- 需在页面中放置 `<button open-type="agreePrivacyAuthorization">` 供用户同意
- 用户未同意前,所有隐私相关 API 和组件都无法调用
- 官方开发指南及 Demo
https://developers.weixin.qq.com/miniprogram/dev/framework/user-privacy/PrivacyAuthorize.html
### 2.4 小程序基本信息
| 状态 | 项目 |
|------|------|
| 已完成 | AppID 已配置:`wx7c07793d82732921` |
| | 确认小程序名称、图标、简介已填写完整 |
| | 确认小程序类目已选择(建议"工具 - 企业管理"或"生活服务" |
| 已完成 | 确认小程序名称、图标、简介已填写完整 |
| 已完成 | 确认小程序类目已选择(建议"工具 - 企业管理"或"生活服务" |
> 审核时会检查这些基本信息。类目选择需注意:部分类目需要上传营业执照等资质文件,提前确认。

View File

@@ -0,0 +1,194 @@
# 桌球运营助手 小程序隐私保护指引
> 本文档用于在微信小程序后台「用户隐私保护指引」中填写。
> 操作路径:微信后台 → 账号设置 → 服务内容声明 → 用户隐私保护指引 → 去完善
> 对应 LAUNCH-CHECKLIST.md 第 2.3 章节。
> 最后更新2026-02-26
---
## 开发者信息
- 小程序名称:桌球运营助手
- 开发者:北京塞伯汀科技有限公司(以下简称"开发者"
---
## 开发者处理的信息
根据法律规定,开发者仅处理实现小程序功能所必要的信息。
### 需要声明的信息类型及用途
| 信息类型 | 是否需要明示同意 | 用途(填入微信后台) |
|---------|:---:|------|
| 微信昵称、头像 | 是 | 用于在小程序内展示你的个人资料,便于门店管理员识别你的身份 |
| 手机号 | 是 | 用于账号申请时填写联系方式,便于门店管理员审核你的身份并与门店人员信息进行匹配 |
| 麦克风 | 是 | 用于智能助手对话功能中的"按住说话"语音输入,将语音转换为文字后发送给助手 |
| 操作日志 | 否 | 用于记录你在小程序内的关键操作(如任务状态变更、备注提交等),以便排查问题和保障服务稳定性 |
| 设备信息 | 否 | 用于适配不同机型的页面显示效果,以及排查兼容性问题,保障小程序正常运行 |
### 建议移除的信息类型
以下信息类型在当前小程序功能中**未使用**,建议在微信后台隐私指引中**不要勾选**,避免过度收集:
| 信息类型 | 不需要的原因 |
|---------|------------|
| 地址 | 小程序不涉及收货地址或地址填写功能 |
| 磁场传感器 | 小程序无指南针、金属探测等功能 |
| 方向传感器 | 小程序无方向导航功能 |
| 陀螺仪传感器 | 小程序无体感交互或 AR 功能 |
| 加速传感器 | 小程序无摇一摇或运动检测功能 |
| 位置信息 | 小程序不需要获取用户地理位置(门店信息通过球房 ID 关联,非 GPS 定位) |
| 所关注账号 | 小程序不涉及公众号关注或社交关系功能 |
| 剪切板 | 小程序不读取剪切板内容 |
> **重要**:微信后台只需勾选实际使用的信息类型。勾选了未使用的类型,审核时可能被要求说明用途,且违反最小必要原则。
---
## 微信后台填写参考(逐项复制)
以下为微信后台各字段的建议填写内容,可直接复制粘贴:
### 微信昵称、头像
```
用于在小程序内展示你的个人资料,便于门店管理员识别你的身份。
```
### 手机号
```
用于账号申请时填写联系方式,便于门店管理员审核你的身份并与门店人员信息进行匹配。
```
### 麦克风
```
用于智能助手对话功能中的"按住说话"语音输入,将语音转换为文字后发送给助手。
```
### 操作日志
```
用于记录你在小程序内的关键操作(如任务状态变更、备注提交等),以便排查问题和保障服务稳定性。
```
### 设备信息
```
用于适配不同机型的页面显示效果,以及排查兼容性问题,保障小程序正常运行。
```
---
## 第三方插件/SDK 信息
桌球运营助手小程序接入的第三方插件/SDK 信息如下:
| SDK/服务名称 | 提供方 | 用途 | 涉及的个人信息 |
|------------|-------|------|-------------|
| TDesign 小程序组件库 | 腾讯 | 提供 UI 组件(按钮、表单、弹窗等),不涉及个人信息收集 | 无 |
| 阿里云百炼DashScope API | 阿里云计算有限公司 | 提供智能助手对话、运营数据分析、备注含金量评分等 AI 能力,后端服务器调用百炼 API 时会传入用户标识和对话内容 | 用户标识user_id、用户身份角色、用户昵称、用户输入的对话内容和备注内容 |
> 说明:
> - TDesign 为纯前端 UI 组件库,不采集任何用户数据。
> - 阿里云百炼为后端调用的 AI 服务(非小程序端 SDK后端将用户标识、身份、昵称及对话内容传给百炼 API 以实现智能助手功能。百炼平台根据传入参数做数据隔离,不同用户只能查询自己有权限的数据。
> - 百炼 API 的个人信息处理规则请参阅:[阿里云百炼隐私政策](https://terms.alicdn.com/legal-agreement/terms/suit_bu1_ali_cloud/suit_bu1_ali_cloud202112071754_83380.html)
> - 如后续接入微信同声传译插件(语音转文字)或其他 SDK需在此处补充。
---
## 未成年人保护
根据相关法律法规的规定,若你是 14 周岁以下的未成年人,你需要和你的监护人共同仔细阅读本指引,并在征得监护人明示同意后继续使用小程序服务。
开发者将根据相关法律法规的规定及本指引内容,处理经监护人同意而收集的未成年人用户信息,并通过本指引「你的权益」部分披露的内容保障未成年人在个人信息处理活动中的各项权利。
---
## 你的权益
关于你的个人信息,你可以通过以下方式与开发者联系,行使查阅、复制、更正、删除等法定权利。
若你在小程序中注册了账号,你可以通过以下方式与开发者联系,申请注销你在小程序中使用的账号。在受理你的申请后,开发者承诺在十五个工作日内完成核查和处理,并按照法律法规要求处理你的相关信息。
### 联系方式(在微信后台选择并填写)
| 联系方式 | 内容 |
|---------|------|
| 电子邮箱 | privacy@saiboting.com |
> 请在微信后台「你的权益」部分点击「增加联系方式」,选择「电子邮箱」,填入上述邮箱地址。
> 如有其他联系方式(如客服电话),也可一并添加。
---
## 开发者对信息的存储
### 存储期限
在微信后台选择:**固定存储期限**
建议填写天数:**365** 天
> 说明:用户数据保留 365 天1 年),超过存储期限后将依法删除或匿名化处理。
> 如用户主动注销账号,开发者将在 15 个工作日内完成数据删除。
开发者承诺,除法律法规另有规定外,开发者对你的信息的保存期限应当为实现处理目的所必要的最短时间。
---
## 信息的使用规则
开发者将会在本指引所明示的用途内使用收集的信息。
如开发者使用你的信息超出本指引目的或合理范围,开发者必须在变更使用目的或范围前,再次以弹窗方式告知并征得你的明示同意。
---
## 信息对外提供
开发者承诺,不会主动共享或转让你的信息至任何第三方,如存在确需共享或转让时,开发者应当直接征得或确认第三方征得你的单独同意。
开发者承诺,不会对外公开披露你的信息,如必须公开披露时,开发者应当向你告知公开披露的目的、披露信息的类型及可能涉及的信息,并征得你的单独同意。
---
## 投诉与建议
你认为开发者未遵守上述约定,或有其他的投诉建议、或未成年人个人信息保护相关问题,可通过以下方式与开发者联系;或者向微信进行投诉。
- 电子邮箱privacy@saiboting.com
---
## 补充文档
了解更多个人信息处理规则可查看补充文档。
> 微信后台支持上传 .txt 格式补充文档(大小不超过 100KB
> 如需上传,可将本文档的核心内容导出为 .txt 文件上传。
---
## 附:微信后台填写操作指南
### 操作步骤
1. 登录 [微信公众平台](https://mp.weixin.qq.com/)
2. 进入「账号设置」→「服务内容声明」→「用户隐私保护指引」→「去完善」
3. 按照上方表格,**仅勾选实际使用的 5 项信息类型**(微信昵称头像、手机号、麦克风、操作日志、设备信息)
4. 逐项填写用途(复制上方「微信后台填写参考」中的文本)
5. 在「第三方插件/SDK 信息」中填写 TDesign 信息(或选择"无"
6. 在「你的权益」中添加联系方式(电子邮箱)
7. 存储期限选择「固定存储期限」,填写 365 天
8. 信息使用规则中告知方式选择「弹窗」
9. 预览确认后提交
### 注意事项
- 隐私保护指引提交后,小程序端需实现隐私授权弹窗组件(见 LAUNCH-CHECKLIST 2.3 第二项)
- 每次新增隐私接口调用时,需回来更新隐私保护指引
- 邮箱地址 `privacy@saiboting.com` 需确保可正常接收邮件,请确认后再填写;如暂无此邮箱,可先使用公司其他可用邮箱

View File

@@ -0,0 +1,35 @@
## 以下内容添加到 LAUNCH-CHECKLIST.md 的 5.4 安全加固之后
### 5.5 微信 API 安全(证书签名)(依赖 3.4
| 状态 | 项目 |
|------|------|
| 已完成 | 微信后台申请 API 安全证书 |
| | 将 RSA 私钥文件放到服务器 `D:\NeoZQYY\secrets\wx_api_private_key.pem` |
| | 将平台证书放到服务器 `D:\NeoZQYY\secrets\wx_api_cert.cer` |
| | 在 `.env.local` 中配置 `WX_API_CERT_SN``WX_API_PRIVATE_KEY_PATH``WX_API_CERT_PATH``WX_API_SYMMETRIC_KEY` |
| | 安装依赖 `pip install cryptography` |
| | 实现签名工具类 `app/utils/wx_api_sign.py` |
| | 在需要签名的 API 调用中集成签名逻辑 |
> 详细使用说明见 [`docs/deployment/wx-api-security-guide.md`](wx-api-security-guide.md)
微信 API 安全提供了四样材料:
| 材料 | 用途 | 存放位置 |
|------|------|----------|
| 证书编号SN | 请求头标识 | `.env.local``WX_API_CERT_SN` |
| `.cer` 证书文件 | 验证微信响应签名 | `D:\NeoZQYY\secrets\wx_api_cert.cer` |
| 对称密钥AES Key | 解密敏感数据(如手机号) | `.env.local``WX_API_SYMMETRIC_KEY` |
| 非对称密钥RSA 私钥) | 对请求签名 | `D:\NeoZQYY\secrets\wx_api_private_key.pem` |
安全要求:
- 私钥文件和对称密钥绝对不能提交到 Git
- `D:\NeoZQYY\secrets\` 目录已在 `.gitignore``server-exclude.txt` 中排除
- 证书有有效期,到期前需在微信后台重新申请
## 同时更新 3.4 密钥配置,补充以下条目:
| | `WX_API_CERT_SN` 写入服务器 `.env.local` |
| | `WX_API_SYMMETRIC_KEY` 写入服务器 `.env.local` |
| | RSA 私钥文件放到 `D:\NeoZQYY\secrets\` |

View File

@@ -0,0 +1,451 @@
# 微信小程序 API 安全(证书 + 签名 + 加密)使用说明
> 校验依据https://developers.weixin.qq.com/miniprogram/dev/server/getting_started/api_signature.html
## 背景
微信小程序部分敏感 API如获取手机号等支持服务通信二次加密和签名
可有效防止数据篡改与泄露。开发者在小程序管理后台「开发 → 开发管理 → 开发设置 → API 安全」
进行密钥配置。
## 你拿到的四样材料
| 材料 | 微信后台对应位置 | 用途 |
|------|-----------------|------|
| 开放平台证书编号SN | API 安全页面显示 | 放在请求头 `Wechatmp-Serial` 中标识证书 |
| `.cer` 平台证书文件 | 配置完公钥后下载 | 验证微信 API 响应的签名RSAwithSHA256 |
| 对称密钥AES256 Key | API 对称密钥处配置 | 加密请求数据 + 解密响应数据AES256_GCM |
| 非对称密钥RSA 私钥) | API 非对称密钥处配置 | 对请求签名RSAwithSHA256 + PSS 填充) |
## 支持的算法组合
微信支持以下算法,在管理后台配置:
| 类型 | 可选算法 |
|------|---------|
| 加密算法 | AES256_GCM、SM4_GCM |
| 签名算法 | RSAwithSHA256、SM2withSM3 |
以下以 AES256_GCM + RSAwithSHA256 为例说明。
## 完整流程概览
```
请求:原始数据 → AES256_GCM 加密 → RSAwithSHA256 签名 → 发送
响应:接收 → RSAwithSHA256 验签 → AES256_GCM 解密 → 原始数据
```
## 配置步骤
### 1. 将密钥文件放到服务器
```powershell
New-Item -ItemType Directory -Path D:\NeoZQYY\secrets -Force
```
将以下文件放入 `D:\NeoZQYY\secrets\`
- `wx_api_private_key.pem` — 应用私钥RSA
- `wx_api_platform_cert.cer` — 平台证书(微信的公钥,用于验签响应)
### 2. 配置环境变量
`apps/backend/.env.local` 中添加:
```env
# API 安全 - 对称密钥编号(微信后台 API 安全页面显示的 SN
WX_API_SYM_SN=你的对称密钥编号
# API 安全 - 对称密钥AES256 KeyBase64 编码的 32 字节密钥)
WX_API_SYMMETRIC_KEY=你的对称密钥明文
# API 安全 - 非对称密钥编号
WX_API_ASYM_SN=你的非对称密钥编号
# API 安全 - RSA 私钥文件路径
WX_API_PRIVATE_KEY_PATH=D:/NeoZQYY/secrets/wx_api_private_key.pem
# API 安全 - 平台证书文件路径(微信的公钥,用于验签响应)
WX_API_PLATFORM_CERT_PATH=D:/NeoZQYY/secrets/wx_api_platform_cert.cer
# API 安全 - 平台证书编号(微信后台显示,非证书内序列号)
WX_API_PLATFORM_CERT_SN=平台证书编号
```
### 3. 后端工具类
`apps/backend/app/utils/` 下新建 `wx_api_sign.py`
```python
"""
微信小程序 API 安全 — 请求加密 + 签名 + 响应验签 + 解密
校验依据:
https://developers.weixin.qq.com/miniprogram/dev/server/getting_started/api_signature.html
流程:
1. 请求加密AES256_GCM 加密请求数据
2. 请求签名RSAwithSHA256 + PSS 填充salt=32对密文签名
3. 响应验签:用平台证书公钥验证响应签名
4. 响应解密AES256_GCM 解密响应数据
"""
import base64
import json
import os
import time
from pathlib import Path
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.x509 import load_pem_x509_certificate
from app.config import get
class WxApiSecurity:
"""微信 API 安全:加密 + 签名 + 验签 + 解密"""
def __init__(self):
self.app_id: str = get("WX_APP_ID", "")
self.sym_sn: str = get("WX_API_SYM_SN", "")
self.asym_sn: str = get("WX_API_ASYM_SN", "")
self.platform_cert_sn: str = get("WX_API_PLATFORM_CERT_SN", "")
# 对称密钥AES256Base64 编码)
sym_key_b64 = get("WX_API_SYMMETRIC_KEY", "")
self.sym_key = base64.b64decode(sym_key_b64) if sym_key_b64 else None
# 加载 RSA 私钥(应用私钥,用于签名请求)
key_path = get("WX_API_PRIVATE_KEY_PATH", "")
if key_path and Path(key_path).exists():
with open(key_path, "rb") as f:
self.private_key = serialization.load_pem_private_key(
f.read(), password=None
)
else:
self.private_key = None
# 加载平台证书(微信公钥,用于验签响应)
cert_path = get("WX_API_PLATFORM_CERT_PATH", "")
if cert_path and Path(cert_path).exists():
with open(cert_path, "rb") as f:
cert = load_pem_x509_certificate(f.read())
self.platform_public_key = cert.public_key()
else:
self.platform_public_key = None
# ========== 请求加密AES256_GCM==========
def encrypt_request(
self, url_path: str, req_data: dict, timestamp: int
) -> dict:
"""
加密请求数据。
参数:
url_path: 完整 API URL含协议不含 query 参数),
"https://api.weixin.qq.com/wxa/business/getuserphonenumber"
req_data: 原始请求参数字典
timestamp: 时间戳
返回:
加密后的请求体 {"iv": "...", "data": "...", "authtag": "..."}
"""
if not self.sym_key:
raise RuntimeError("对称密钥未配置")
# 在原数据内添加安全字段
req_data["_n"] = base64.b64encode(os.urandom(16)).decode()
req_data["_appid"] = self.app_id
req_data["_timestamp"] = timestamp
plaintext = json.dumps(req_data, ensure_ascii=False).encode("utf-8")
# GCM 额外认证数据AADurlpath|appid|timestamp|sn
aad = f"{url_path}|{self.app_id}|{timestamp}|{self.sym_sn}".encode()
# 12 字节随机 IV
iv = os.urandom(12)
aesgcm = AESGCM(self.sym_key)
# encrypt 返回 ciphertext + tag最后 16 字节是 tag
ct_with_tag = aesgcm.encrypt(iv, plaintext, aad)
ciphertext = ct_with_tag[:-16]
authtag = ct_with_tag[-16:]
return {
"iv": base64.b64encode(iv).decode(),
"data": base64.b64encode(ciphertext).decode(),
"authtag": base64.b64encode(authtag).decode(),
}
# ========== 请求签名RSAwithSHA256 + PSS==========
def sign_request(
self, url_path: str, post_data: str, timestamp: int
) -> dict[str, str]:
"""
生成签名请求头。
参数:
url_path: 完整 API URL含协议不含 query 参数)
post_data: 加密后的请求体 JSON 字符串
timestamp: 时间戳(与加密时一致)
返回:
签名相关的请求头字典
"""
if not self.private_key:
raise RuntimeError("RSA 私钥未配置")
# 待签名串格式urlpath\nappid\ntimestamp\npostdata
# 字段之间用换行符分隔,末尾无额外换行符
payload = f"{url_path}\n{self.app_id}\n{timestamp}\n{post_data}"
# RSAwithSHA256 + PSS 填充salt 长度为 32
signature = self.private_key.sign(
payload.encode("utf-8"),
asym_padding.PSS(
mgf=asym_padding.MGF1(hashes.SHA256()),
salt_length=32,
),
hashes.SHA256(),
)
return {
"Wechatmp-Appid": self.app_id,
"Wechatmp-TimeStamp": str(timestamp),
"Wechatmp-Signature": base64.b64encode(signature).decode(),
}
# ========== 响应验签 ==========
def verify_response(
self, url_path: str, resp_data: str, timestamp: str,
signature_b64: str, serial: str
) -> bool:
"""
验证微信 API 响应签名。
参数:
url_path: 请求的 API URL
resp_data: 响应体字符串
timestamp: 响应头 Wechatmp-TimeStamp
signature_b64: 响应头 Wechatmp-SignatureBase64
serial: 响应头 Wechatmp-Serial平台证书编号
"""
if not self.platform_public_key:
raise RuntimeError("平台证书未配置")
# 待验签串urlpath\nappid\ntimestamp\nrespdata
payload = f"{url_path}\n{self.app_id}\n{timestamp}\n{resp_data}"
signature = base64.b64decode(signature_b64)
try:
self.platform_public_key.verify(
signature,
payload.encode("utf-8"),
asym_padding.PSS(
mgf=asym_padding.MGF1(hashes.SHA256()),
salt_length=asym_padding.PSS.AUTO,
),
hashes.SHA256(),
)
return True
except Exception:
return False
# ========== 响应解密AES256_GCM==========
def decrypt_response(
self, resp_body: dict, url_path: str, timestamp: str
) -> dict:
"""
解密微信 API 响应数据。
参数:
resp_body: 响应体 {"iv": "...", "data": "...", "authtag": "..."}
url_path: 请求的 API URL
timestamp: 响应头 Wechatmp-TimeStamp
"""
if not self.sym_key:
raise RuntimeError("对称密钥未配置")
iv = base64.b64decode(resp_body["iv"])
ciphertext = base64.b64decode(resp_body["data"])
authtag = base64.b64decode(resp_body["authtag"])
# AAD 格式与请求时一致
aad = f"{url_path}|{self.app_id}|{timestamp}|{self.sym_sn}".encode()
aesgcm = AESGCM(self.sym_key)
plaintext = aesgcm.decrypt(iv, ciphertext + authtag, aad)
return json.loads(plaintext)
# ========== 便捷方法:加密 + 签名一步到位 ==========
def prepare_request(
self, url_path: str, req_data: dict
) -> tuple[str, dict[str, str]]:
"""
一步完成加密 + 签名,返回 (加密后的body字符串, 请求头字典)。
"""
timestamp = int(time.time())
# 1. 加密
encrypted = self.encrypt_request(url_path, req_data, timestamp)
body_str = json.dumps(encrypted, ensure_ascii=False)
# 2. 签名
headers = self.sign_request(url_path, body_str, timestamp)
headers["Content-Type"] = "application/json"
return body_str, headers
# 模块级单例
wx_security = WxApiSecurity()
```
### 4. 使用示例:获取手机号
```python
import httpx
import json
from app.utils.wx_api_sign import wx_security
async def get_user_phone_number(access_token: str, code: str) -> dict:
"""
调用微信接口获取用户手机号(带加密 + 签名)。
code 来自小程序端 getPhoneNumber 按钮回调。
接口文档:
https://developers.weixin.qq.com/miniprogram/dev/server/API/user-info/phone-number/api_getphonenumber.html
"""
url_path = "https://api.weixin.qq.com/wxa/business/getuserphonenumber"
full_url = f"{url_path}?access_token={access_token}"
# 加密 + 签名
body_str, headers = wx_security.prepare_request(
url_path, {"code": code}
)
async with httpx.AsyncClient() as client:
resp = await client.post(full_url, content=body_str, headers=headers)
# 验签(从响应头获取签名信息)
resp_sig = resp.headers.get("Wechatmp-Signature", "")
resp_ts = resp.headers.get("Wechatmp-TimeStamp", "")
resp_serial = resp.headers.get("Wechatmp-Serial", "")
if resp_sig:
verified = wx_security.verify_response(
url_path, resp.text, resp_ts, resp_sig, resp_serial
)
if not verified:
raise ValueError("微信响应签名验证失败")
# 解密响应
resp_body = resp.json()
if "iv" in resp_body and "data" in resp_body:
return wx_security.decrypt_response(resp_body, url_path, resp_ts)
else:
# 未加密的响应(错误码等)
return resp_body
```
### 不使用加密的简单调用(仅签名)
如果某些接口只需要签名不需要加密,可以只用签名部分:
```python
async def call_wx_api_sign_only(
access_token: str, url_path: str, data: dict
) -> dict:
"""仅签名,不加密请求体"""
full_url = f"{url_path}?access_token={access_token}"
timestamp = int(time.time())
body_str = json.dumps(data, ensure_ascii=False)
headers = wx_security.sign_request(url_path, body_str, timestamp)
headers["Content-Type"] = "application/json"
async with httpx.AsyncClient() as client:
resp = await client.post(full_url, content=body_str, headers=headers)
return resp.json()
```
## 依赖安装
```bash
pip install cryptography httpx
```
- `cryptography` — RSA 签名PSS 填充)+ AES256_GCM 加解密 + X.509 证书解析
- `httpx` — 异步 HTTP 客户端
注意API 安全使用的是 AES256_GCM不是 CBC`cryptography` 库原生支持,不需要 `pycryptodome`
## 请求头说明
| Header | 说明 |
|--------|------|
| `Wechatmp-Appid` | 当前小程序的 AppID |
| `Wechatmp-TimeStamp` | 签名时的时间戳 |
| `Wechatmp-Signature` | 签名数据Base64 编码) |
## 响应头说明
| Header | 说明 |
|--------|------|
| `Wechatmp-Appid` | 小程序 AppID |
| `Wechatmp-TimeStamp` | 签名时间戳 |
| `Wechatmp-Serial` | 平台证书编号(在 MP 管理页获取,非证书内序列号) |
| `Wechatmp-Signature` | 平台证书签名Base64 |
| `Wechatmp-Serial-Deprecated` | 即将失效的证书编号(仅证书更换周期内出现) |
| `Wechatmp-Signature-Deprecated` | 即将失效的证书签名(仅证书更换周期内出现) |
> 若 `Wechatmp-Serial-Deprecated` 出现,说明当前使用的平台证书即将过期,请尽快到 MP 后台更新。
## 签名算法关键细节
1. 签名使用 PSS 填充方式salt 长度为 32
2. 待签名串格式:`urlpath\nappid\ntimestamp\npostdata`,字段间用 `\n` 分隔,末尾无额外换行
3. urlpath 需要包含 HTTP 协议头(如 `https://api.weixin.qq.com/wxa/...`),不包含 URL 参数
4. PSS 签名包含随机因子,每次签名结果都会变化,这是正常的
## 加密算法关键细节
1. 使用 AES256_GCM 模式(不是 CBC
2. IV 为 12 字节随机字符串
3. AAD额外认证数据格式`urlpath|appid|timestamp|sn`,用竖线 `|` 分隔
4. 加密后的 data 明文需要额外包含 `_n`(随机串)、`_appid``_timestamp` 三个安全字段
5. 不包含 AccessTokenAccessToken 放在 URL 参数中)
## 错误码
| 错误码 | 说明 |
|--------|------|
| 40230 | 缺少 Wechatmp-Serial |
| 40231 | 缺少 Wechatmp-Timestamp |
| 40232 | 缺少 Wechatmp-Signature |
| 40233 | 缺少 Wechatmp-Appid |
| 40234 | 签名错误 |
| 40235 | 加密错误 |
| 40236 | 无效的 Wechatmp-Appid |
| 40237 | Wechatmp-Appid 和 Token 不匹配 |
| 40238 | 开发者未设置对称密钥 |
| 40239 | 开发者未设置公钥 |
| 40240 | 超时的数据 |
## 安全注意事项
- RSA 私钥文件绝对不能提交到 Git放在 `D:\NeoZQYY\secrets\` 并确保 `.gitignore` 排除
- 对称密钥只放在 `.env.local` 中,不要硬编码
- 平台证书有有效期,注意响应头中的 `Wechatmp-Serial-Deprecated` 提示
- 测试环境和正式环境使用同一套密钥API 安全是 AppID 级别的)
## 参考文档
- [API 安全签名加密指南(官方)](https://developers.weixin.qq.com/miniprogram/dev/server/getting_started/api_signature.html)
- [获取手机号接口](https://developers.weixin.qq.com/miniprogram/dev/server/API/user-info/phone-number/api_getphonenumber.html)

View File

@@ -0,0 +1,283 @@
# 微信消息推送 — 安全模式AES 加解密)说明
> 校验依据https://developers.weixin.qq.com/miniprogram/dev/framework/server-ability/message-push.html
## 当前状态
- GET 验签:已通过,不受加密模式影响
- POST 消息接收:选择了「安全模式」,微信推送的消息体为 AES 加密,需要解密后才能读取
## 需要的配置
`apps/backend/.env.local` 中添加:
```env
# 微信后台「消息推送」页面的 EncodingAESKey43位字符串
WX_ENCODING_AES_KEY=你在微信后台生成的那个43位字符串
# 小程序的 AppID
WX_APP_ID=你的小程序AppID
```
## 安全模式与明文模式的关键区别
安全模式下,微信 POST 推送的 URL 参数会多出两个字段:
| 参数 | 说明 |
|------|------|
| `signature` | 普通签名token + timestamp + nonce仅用于明文模式 |
| `msg_signature` | 消息签名token + timestamp + nonce + Encrypt安全模式必须用这个验签 |
| `encrypt_type` | 值为 `aes`,表示安全模式 |
| `timestamp` | 时间戳 |
| `nonce` | 随机数 |
**重要:安全模式下验签必须使用 `msg_signature`,不要使用 `signature`**
验签算法:将 token、timestamp、nonce、Encrypt包体内的字段四个参数字典序排序后拼接做 SHA1`msg_signature` 比对。
## 解密原理
安全模式下,微信 POST 推送的 JSON 结构为:
```json
{
"Encrypt": "加密后的密文字符串"
}
```
解密流程:
1. 从请求体取出 `Encrypt` 字段
2. AESKey = Base64_Decode(EncodingAESKey + "="),得到 32 字节 AES 密钥
3. 将 Encrypt 密文进行 Base64 解码,得到 TmpMsg
4. 用 AESKey 对 TmpMsg 进行 AES-256-CBC 解密IV 取 AESKey 前 16 字节),去除 PKCS#7 填充K=32
5. 解密后的明文格式:`random(16B) + msg_len(4B) + msg + appid`
- random(16B)16 字节随机字符串
- msg_len消息长度4 字节网络字节序(大端)
- msg解密后的明文消息
- appid小程序 AppID需验证是否与自身一致
## 回包加密(安全模式下需要加密回包)
如果需要回复非空内容(非 `success`),安全模式下回包也需要加密:
1. 构造 FullStr = random(16B) + msg_len(4B) + msg + appid
2. 用 AESKey 进行 AES-256-CBC 加密PKCS#7 填充K=32
3. Base64 编码得到 Encrypt
4. 生成 MsgSignature将 token、TimeStamp、Nonce、Encrypt 四个参数字典序排序拼接后 SHA1
5. 回包格式JSON
```json
{
"Encrypt": "加密后的密文",
"MsgSignature": "签名",
"TimeStamp": ,
"Nonce": "随机数"
}
```
> 如果只需回复 `success`,无需加密,直接返回纯文本 `success` 即可。
## 代码改动
### 方案 A使用微信官方示例代码推荐
微信官方提供了多种语言的加解密示例代码PHP、Java、C++、Python、C#
建议优先使用官方示例https://developers.weixin.qq.com/miniprogram/dev/framework/server-ability/message-push.html
(页面底部「示例下载」链接)
安装依赖:
```bash
pip install pycryptodome # AES 加解密
```
### 方案 B手动实现解密工具
`apps/backend/app/utils/` 下新建 `wx_crypto.py`
```python
"""
微信消息加解密工具
校验依据:
https://developers.weixin.qq.com/miniprogram/dev/framework/server-ability/message-push.html
PKCS#7 填充说明(微信官方规范):
- K 为秘钥字节数(采用 32
- Buf 为待加密的内容N 为其字节数
- Buf 需要被填充为 K 的整数倍
- 在 Buf 的尾部填充 (K - N%K) 个字节,每个字节的内容是 (K - N%K)
"""
import base64
import hashlib
import struct
from Crypto.Cipher import AES
BLOCK_SIZE = 32 # 微信规范PKCS#7 填充使用 K=32
class WXBizMsgCrypt:
"""微信消息加解密"""
def __init__(self, token: str, encoding_aes_key: str, app_id: str):
self.token = token
self.app_id = app_id
# AESKey = Base64_Decode(EncodingAESKey + "=")
self.aes_key = base64.b64decode(encoding_aes_key + "=")
self.iv = self.aes_key[:16] # 初始向量取密钥前 16 字节
def check_msg_signature(
self, msg_signature: str, timestamp: str, nonce: str, encrypt: str
) -> bool:
"""
安全模式验签。
注意:安全模式下必须用 msg_signature 验签,参与排序的是四个参数:
token、timestamp、nonce、Encrypt包体内的字段
"""
items = sorted([self.token, timestamp, nonce, encrypt])
hash_str = hashlib.sha1("".join(items).encode("utf-8")).hexdigest()
return hash_str == msg_signature
def decrypt(self, encrypt_text: str) -> str:
"""
解密微信推送的加密消息。
返回解密后的消息明文JSON 字符串)。
"""
cipher = AES.new(self.aes_key, AES.MODE_CBC, self.iv)
decrypted = cipher.decrypt(base64.b64decode(encrypt_text))
# 去除 PKCS#7 填充K=32
pad_len = decrypted[-1]
if pad_len < 1 or pad_len > BLOCK_SIZE:
raise ValueError(f"无效的 PKCS#7 填充: {pad_len}")
content = decrypted[:-pad_len]
# FullStr = random(16B) + msg_len(4B) + msg + appid
msg_len = struct.unpack("!I", content[16:20])[0]
msg = content[20 : 20 + msg_len].decode("utf-8")
from_app_id = content[20 + msg_len :].decode("utf-8")
# 校验 AppID
if from_app_id != self.app_id:
raise ValueError(
f"AppID 不匹配: 期望 {self.app_id}, 实际 {from_app_id}"
)
return msg
def encrypt(self, msg: str) -> str:
"""
加密回包消息(安全模式下回复非 success 内容时需要)。
返回 Base64 编码的密文。
"""
import os
# 构造 FullStr = random(16B) + msg_len(4B) + msg + appid
msg_bytes = msg.encode("utf-8")
app_id_bytes = self.app_id.encode("utf-8")
full_str = (
os.urandom(16)
+ struct.pack("!I", len(msg_bytes))
+ msg_bytes
+ app_id_bytes
)
# PKCS#7 填充K=32
pad_len = BLOCK_SIZE - (len(full_str) % BLOCK_SIZE)
full_str += bytes([pad_len] * pad_len)
cipher = AES.new(self.aes_key, AES.MODE_CBC, self.iv)
encrypted = cipher.encrypt(full_str)
return base64.b64encode(encrypted).decode("utf-8")
def generate_msg_signature(
self, timestamp: str, nonce: str, encrypt: str
) -> str:
"""生成回包的 MsgSignature"""
items = sorted([self.token, timestamp, nonce, encrypt])
return hashlib.sha1("".join(items).encode("utf-8")).hexdigest()
```
### 改动 `wx_callback.py` 的 POST 处理
```python
# 在文件顶部新增导入
import json
from app.utils.wx_crypto import WXBizMsgCrypt
from app.config import get
# 初始化解密器(模块级别)
wx_crypt = WXBizMsgCrypt(
token=get("WX_CALLBACK_TOKEN", ""),
encoding_aes_key=get("WX_ENCODING_AES_KEY", ""),
app_id=get("WX_APP_ID", ""),
)
@router.post("/callback")
async def receive_message(
request: Request,
msg_signature: str = Query("", alias="msg_signature"),
signature: str = Query(""),
timestamp: str = Query(""),
nonce: str = Query(""),
encrypt_type: str = Query(""),
):
body = await request.body()
raw = json.loads(body)
# 安全模式:用 msg_signature 验签四参数token + timestamp + nonce + Encrypt
if encrypt_type == "aes" and "Encrypt" in raw:
if not wx_crypt.check_msg_signature(
msg_signature, timestamp, nonce, raw["Encrypt"]
):
logger.warning("安全模式消息推送验签失败")
return Response(content="signature mismatch", status_code=403)
decrypted_str = wx_crypt.decrypt(raw["Encrypt"])
data = json.loads(decrypted_str)
else:
# 明文模式 / 兼容模式:用 signature 验签(三参数)
if not _check_signature(signature, timestamp, nonce):
logger.warning("明文模式消息推送验签失败")
return Response(content="signature mismatch", status_code=403)
data = raw
logger.info(
"收到微信推送: MsgType=%s, Event=%s",
data.get("MsgType", "?"),
data.get("Event", "?"),
)
# TODO: 根据 MsgType/Event 分发处理
# 回复 success 无需加密
return Response(content="success", media_type="text/plain")
```
## 依赖安装
```bash
pip install pycryptodome
```
注意:是 `pycryptodome` 不是 `pycrypto`(后者已废弃)。
## 测试方法
部署后可以用微信开发者工具发送客服消息,或者关注/取关小程序触发事件推送,
查看后端日志确认消息是否正确解密:
```bash
# 在 Windows 服务器上查看后端日志
# 应该能看到 "收到微信推送: MsgType=event, Event=subscribe" 之类的输出
```
微信也提供了调试工具,可在消息推送配置页面使用「请求构造」和「调试工具」进行测试。
## 注意事项
- `EncodingAESKey` 必须和微信后台配置的完全一致,一个字符都不能错
- 如果后续在微信后台重新生成了 `EncodingAESKey`,服务器端也要同步更新并重启
- GET 验签逻辑不需要改动,安全模式只影响 POST 消息体
- PKCS#7 填充的 K 值为 32不是常见的 16这是微信的特殊规范
- 安全模式下验签用 `msg_signature`(四参数),不要用 `signature`(三参数)