
Wechatpay Payment Integration
- 605 installs
- 320 repo stars
- Updated July 30, 2026
- wechatpay-apiv3/wechatpay-skills
Helps with ai & agent building tasks.
About
wechatpay-payment-integration is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- wechatpay-payment-integration
- AI & Agent Building
- AI-coding skill
Wechatpay Payment Integration by the numbers
- 605 all-time installs (skills.sh)
- +95 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,577 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wechatpay-apiv3/wechatpay-skills --skill wechatpay-payment-integrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 605 |
|---|---|
| repo stars | ★ 320 |
| Last updated | July 30, 2026 |
| Repository | wechatpay-apiv3/wechatpay-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
微信支付全产品接入指引
全局交互规范
‼️ 本规范所有能力、所有对话轮次通用,优先级高于各能力局部规则。
1. 所有问题必须得到用户明确回答后才能继续。 一次提多个问题时逐一检查每个回答;未答的再次追问,严禁自行假设、推断或使用默认值。 2. 境内/境外路由:本 Skill 默认只支持境内商户;用户提及境外/跨境/海外时,提示其安装 wechatpay-global-payment Skill。 3. 接入模式前置确认:使用任何能力前先确认是商户模式还是服务商模式及 API 版本(V3 / V2);优先从记忆中读取,无则询问用户。若用户不确定自身角色或 API 版本,读取以下文档协助用户判断:
- 接入模式
- APIv3概述
⚠️ 同一项目可能混用 V2 与 V3 接口(如 JSAPI 调起支付用 V2、查单用 V3),API 版本需按具体接口/产品分别确认。
4. 分步确认协议(知识问答除外,排查 / 分析 / 执行操作时遵守):
- ① 明确需求:先给出初步判断或原因分析,不堆参数清单。
- ② 征得同意:主动说下一步能做什么,等用户明确同意再继续;严禁未表态就收参数或执行。
- ③ 收集信息:同意后告知需要哪些信息并逐项收集,收齐才执行。
- ④ 执行前确认:执行前简述即将做什么,确认同意再执行;线上环境额外提示风险。
5. 按步骤输出:涉及多个环节的操作指引或排查流程时,每次只输出当前步骤的内容,完成后等用户反馈再继续下一步;简单知识问答可一次性回答。 6. 角色与版本存入记忆:商户角色和 API 版本属固定信息,首次询问得到答复后立即使用记忆功能将其持久化存储,后续调用 skill 不再重复询问,用户可随时要求修改或删除已存储的记忆。 7. 【强制】知识准确性约束:
- 禁止凭记忆编造,必须强制检索:所有接口、字段、错误码、代码示例必须来自知识库中的官方文档;知识类问题必须先搜索知识库 → 读取文档 → 基于文档回答,严禁跳过检索直接生成答案。
- 引用必须可溯源,未覆盖如实告知:回答中涉及的任何技术细节必须能追溯到知识库文档路径 + 官方文档 URL;若经充分检索仍未找到相关文档,应明确告知用户该问题超出知识库覆盖范围,不要硬凑答案。
8. 文档加载失败时:若任何文档链接无法读取(文件不存在),先执行知识库同步脚本 python3 <SKILL目录>/scripts/wechatpay-docs-sync.py update 再重试。
前置步骤(Skill 加载后立即执行)
⚠️ 优先级高于一切其他操作(包括读取文档、提问、回复用户)。不得跳过或延后。
1. 知识库同步:运行 python3 <SKILL目录>/scripts/wechatpay-docs-sync.py update,等待完成。每 12 小时执行一次即可,若不确定上次执行时间,直接运行。 2. 加载通用文档:
- 📄 基础概念及业务介绍
- 📄 知识库使用指南
---
能力概览
1. 产品选型 — 根据用户业务场景匹配并推荐合适的微信支付产品 2. 示例代码 — 根据用户索要的接口和开发语言,给出官方示例代码和接口文档 URL 3. 接入质量评估 — 以金融支付专家视角扫描用户接入代码,覆盖安全合规、资金链路及开发时业务常见质量问题,按 🔴🟡🟠 分级输出问题清单和修复方向 4. 答疑与排障 — 解答接入中遇到的各类问题,根据错误码或问题现象定位原因并给出解决方案
---
能力1:产品选型
当用户不确定该用哪种微信支付产品、或想了解各产品区别和适用场景时使用此能力。
加载:产品总览
1. 先读「微信支付产品总览」,根据用户业务场景匹配推荐产品并将产品概述发给用户确认;信息不足以匹配时,先向用户追问业务场景细节再选型。 2. 用户想了解更多细节时,按使用指南定位到该产品的「产品介绍」+「开发接入准备」文档,读取后回答。
---
能力2:示例代码
当用户需要某个微信支付接口的示例代码或接口文档时使用此能力。
1. 严格基于官方文档:所有示例代码必须来源于知识库中的官方文档,不得凭模型记忆生成接口、字段或代码片段。信息不全时,先向用户追问。 2. 官方语言(curl / Java / Go):按知识库使用指南定位到该产品 API列表/ 下的接口文档,读取对应语言的请求示例文件输出;前端调起 / 回调类接口无后端请求示例时,直接给出该接口文档内容。 3. 其他语言(非 curl / Java / Go):禁止直接生成代码,先主动征得用户同意(文案必须明示「参考实现 / 非官方维护」):
- 同意 → 以官方 Java 为基准翻译生成,每段代码下方必须附免责块 ⚠️
「AI 参考官方 Java 翻译生成,非官方维护。」 「请开发人员自行审查 AI 生成的代码逻辑,上线前充分测试以确保其适用性与准确性,AI 不对生成代码的正确性承担责任。」
- 未同意 → 只发官方 curl / Java / Go 文档链接(curl 不依赖特定编程语言,适合作为兜底参考)。
---
能力3:接入质量评估
当用户希望对已有的接入代码做质量审查或上线前检查时使用此能力。
加载:接入质量检查清单
1. 加载接入质量检查清单(质检人设 + 三大铁律 + 通用问题雷达)。 2. 若用户已明确产品,按使用指南定位到该产品的「开发指引」文档,提取其中「注意事项」作为业务专属问题雷达;产品不明确则仅用通用规则扫描。 3. 合并「通用清单 + 业务专属注意事项(如有)」→ 扫描 → 追链路 → 做预演 → 按 🔴🟡🟠 分级输出问题清单,致命问题置顶,每个问题给修复方向。
---
能力4:答疑与排障
‼️ 路由规则:凡是不属于能力 1(产品选型)、能力 2(示例代码)、能力 3(接入质量评估)的用户问题,一律进入本能力处理。 包括但不限于:知识查询、流程说明、接口规则咨询、字段含义、错误码含义、报错排查等。
>
本能力是默认兜底能力——当无法明确匹配到能力 1/2/3 时,必须进入本能力的子模块流程。
问题识别与分流
根据用户输入判断问题类型,分流到对应子模块:
用户问题
|
+-- 需要查单排障(贴了接口报错/异常响应想定位原因,或提供了订单号想确认交易状态)
| |
| └──> APIv3 接口动态排障
|
+-- 其他所有问题(知识类问题、流程咨询、接口说明、字段含义、错误码释义、产品规则、回调格式等)
|
└──> 文档检索与问答 【默认分支】子模块清单
| 子模块 | 功能 |
|---|---|
| 文档检索与问答 | 默认子模块。检索本地同步的微信支付官方文档知识库,根据用户问题查找相关文档并作答。 |
| APIv3接口动态排障 | 查询支付单、退款单,协助排查查单失败 |
调用原则
1. 先加载子模块文档再行动:确定分流方向后,必须先 Read 对应子模块的 reference 文档(如 ./references/文档检索与问答.md)获取完整工作流,严格按其中定义的步骤顺序执行。禁止跳过加载子模块文档直接自行搜索/读取知识库文件。 2. 根据当前步骤按需读取 references/ 下的其他补充文档,不要一次性全量加载 3. 文档检索与问答作答后,若判断仍需实际查单才能确认(如用户提到具体订单、或文档方案需验证交易状态),主动询问用户是否需要帮忙查单,同意后进入 APIv3 接口动态排障流程
---
以下信息与技能能力无关,仅供查阅。
📋 用户调研
如果您有任何建议或反馈,欢迎填写:微信支付 Skill 用户调研问卷
APIv3 接口动态排障
前置依赖
进入本流程前,须确认 `wechatpay-dev-cli` 已安装且可用(安装详见 wechatpay-dev-cli使用说明)。
1. 校验 CLI:执行 wechatpay-dev-cli --version,能正常输出版本号(如 1.0.0)再继续。 2. 未安装时:按使用说明安装(公网 npm install -g @tenpay/wechatpay-dev-cli),安装后再次执行 --version 确认。
校验通过后再执行后续的流程。
流程
flowchart LR
A[1. 收集信息] --> B[2. api build 生成 signMessage]
B --> C[3. 获取签名/鉴权信息]
C --> C1[方式A:开发者自行提供鉴权要素]
C --> C2[方式B:本地 extract_and_sign 签名]
C1 --> D[4. api call]
C2 --> D核心约定(流程编排,勿写入脚本输出)
| 步骤 | 职责 |
|---|---|
| Step 1 `api list` | 确认 mode(merchant/partner)、接口ID 与查单参数,组装 --params JSON |
| Step 2 `api build` | 用 Step 1 的 --params 生成 signMessage;缺参会报错,补全后重试;Agent 从 CLI 输出的 JSON 中保存 `signMessage` 原文(禁止篡改) |
| Step 3 方式 A | 开发者回传 Authorization 头,或 serial_no + timestamp + nonce_str + signature |
| Step 3 方式 B | 本机 extract_and_sign 对 Step 2 的 signMessage 签名,回传脚本结果 |
| Step 4 `api call` | 仅使用 Step 3 回传的 serial_no / timestamp / nonce_str / signature 拼 Authorization;可与 Step 2 嵌入的 timestamp/nonce 不一致(方式 A 常见) |
方式 B 须对 Step 2 的signMessage原样签名,故回传的timestamp/nonce_str应与该串内一致。
CLI 与参数约定
- 命令入口:
wechatpay-dev-cli - 模式:
--mode merchant(普通商户)或--mode partner(服务商/合作伙伴) - 业务参数:
--params传 inline JSON 或@文件路径(推荐 Windows 用@file,避免 shell 剥引号) - 输出:
list/build/call默认输出 JSON - `api build` 示例(
payment.QueryByOutTradeNo,merchant):
macOS / Linux:
wechatpay-dev-cli api build payment.QueryByOutTradeNo \
--mode merchant \
--params '{"path":{"out_trade_no":"20260101001"},"query":{"mchid":"1900007291"}}'Windows PowerShell(用 @file 传参,一行;Agent 终端下 inline JSON 易报 CLIXML):
[IO.File]::WriteAllText("$env:TEMP\wechatpay-params.json",'{"path":{"out_trade_no":"20260101001"},"query":{"mchid":"1900007291"}}'); wechatpay-dev-cli api build payment.QueryByOutTradeNo --mode merchant --params "@$env:TEMP\wechatpay-params.json"Step 1: 收集 API 请求信息
1.1 确认身份类型
请开发者确认身份(二选一):
- 普通商户 →
--mode merchant - 合作伙伴(服务商) →
--mode partner
勿泛化收集:不要提前索要mchid/sp_mchid/sub_mchid;先确定要排查的接口ID,再按 1.3 脚本输出逐项收集。
1.2 查询接口目录
在 1.1 确定 --mode 后,列出当前身份下支持的查单接口,供开发者选择要排查的接口ID:
wechatpay-dev-cli api list --mode <merchant|partner>将 CLI 输出的接口列表展示给开发者,由其选定一个 接口ID(如 payment.QueryByOutTradeNo)。
1.3 查询接口所需参数并收集
针对选定的接口ID,查看该接口在对应 mode 下需要填写的 Path / Query 参数:
wechatpay-dev-cli api list <接口ID> --mode <merchant|partner>根据 CLI 输出 JSON 中的 parameters 字段(in=path / in=query 且 required=true),向开发者逐项索要实际值,并组装为 --params JSON(path 参数放 path,query 参数放 query)。参数是否齐全在 Step 2 的 api build 中校验——缺参时 CLI 会报错(如 错误: 缺少必填参数: mchid),按提示补全后重试即可。
---
Step 2: 生成待签名串(api build)
Agent 在本地执行 api build(使用 Step 1 组装的 --params,传参方式见「CLI 与参数约定」),从 CLI 输出的 JSON 中读取并保存 `signMessage` 原文(禁止篡改)。若报缺参错误,回到 Step 1.3 向开发者补要对应字段后重试:
wechatpay-dev-cli api build <接口ID> \
--mode <merchant|partner> \
--params '<JSON>'CLI 输出示例:
{
"id": "payment.QueryByWxTradeNo",
"mode": "merchant",
"signMessage": "GET\n/v3/pay/transactions/id/4200003100202606015015753674?mchid=1900007291\n1554208460\n593BEC0C930BF1AFEB40B4A08C8FB242\n\n",
"timestamp": "1554208460",
"nonce_str": "593BEC0C930BF1AFEB40B4A08C8FB242"
}---
Step 3: 获取签名 / 鉴权信息
Step 3 开场话术
Step 2 完成,待签名串已生成。
---
Step 3:获取签名值
请选择一种方式:
【方式 A】你已有签名结果,请任选一种回传:
1) 完整 Authorization 请求头(推荐)
示例:Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900007291",nonce_str="593BEC0C930BF1AFEB40B4A08C8FB242",signature="...",timestamp="1554208460",serial_no="408B07E79B8269FEC3D5D3E6AB8ED163A6A380DB"
2) 分别提供以下 4 项(与请求头中字段一致):
· API 证书序列号(serial_no)
· 时间戳(timestamp,秒级 Unix 时间)
· 随机串(nonce_str)
· 签名值(signature,Base64)
【方式 B】还没有签名
我将提供一条可在本机终端执行的命令;执行后,把输出里「签名结果开始」到「签名结果结束」之间的内容整段复制回传即可。流程约束:Step 3 未拿到签名 / 鉴权信息前,不得进入 Step 4。
方式 A:开发者自行提供
- 选项 1:原样粘贴完整
Authorization请求头。 - 选项 2:分别提供 API 证书序列号、时间戳、随机串、签名值(对应
serial_no、timestamp、nonce_str、signature)。 - 禁止对开发者说「四元组」「鉴权四元组」等术语。
- 回传的
timestamp/nonce_str可与 Step 2 串内值不一致;Step 4 只使用 Step 3 回传值。 - 不输出本地脚本。
方式 B:本地 extract_and_sign
开发者选择方式 B 时:
- 只输出一个命令代码块(可选一句「请在本机终端执行」);收尾说明:将终端里「签名结果开始」到「签名结果结束」之间的内容整段复制回传。
- 禁止:附「执行步骤」列表;在对话中出现「P12」「证书路径」「终端会提示输入密码」等;向开发者索要真实证书路径。
拼接规则:
| 参数 | Agent 如何填写 |
|---|---|
--signString / -SignString | Step 2 保存的 signMessage 原文(单引号包裹) |
--filePath / -FilePath | 占位路径 /path/to/apiclient_cert.p12 或 C:\path\to\apiclient_cert.p12 |
--password / -Password | Step 1 的 mchid(merchant)或 sp_mchid(partner) |
macOS / Linux 示例(mchid=1900007291):
bash <SKILL目录>/scripts/bash/extract_and_sign.sh \
--filePath "/path/to/apiclient_cert.p12" \
--password "1900007291" \
--signString 'GET\n/v3/pay/transactions/id/4200003100202606015015753674?mchid=1900007291\n1554208460\n593BEC0C930BF1AFEB40B4A08C8FB242\n\n'Windows 示例(若报编码相关 ParserError,改用 pwsh 重试):
powershell -ExecutionPolicy Bypass -File "<SKILL目录>\scripts\powershell\extract_and_sign.ps1" `
-FilePath "C:\path\to\apiclient_cert.p12" `
-Password "1900007291" `
-SignString 'GET\n/v3/pay/transactions/out-trade-no/202606021034379036?mchid=1900006891\n1780912534\n05E839355D63BAB809D98A41D6210F24\n\n'Agent 从开发者回传内容中,在 「签名结果开始」与「签名结果结束」 标识之间提取下列字段,供 Step 4 使用:
| 终端行前缀 | 用途 |
|---|---|
API 证书序列号(serial_no): | Step 4 --serial_no |
时间戳(timestamp): | Step 4 --timestamp |
随机串(nonce_str): | Step 4 --nonce_str |
签名值(signature): | Step 4 --signature |
API 证书中的商户号: | Step 4.1 与 Step 1 商户号比对 |
方式 B 预期输入(开发者从终端复制回传)
开发者在本地执行方式 B 命令后,终端打印类似以下内容;复制「签名结果开始」到「签名结果结束」整段回传给 Agent:
---------- 签名结果开始 ----------
API 证书序列号(serial_no): 408B07E79B8269FEC3D5D3E6AB8ED163A6A380DB
时间戳(timestamp): 1554208460
随机串(nonce_str): 593BEC0C930BF1AFEB40B4A08C8FB242
API 证书中的商户号: 1900007291
签名值(signature): ...
---------- 签名结果结束 ----------时间戳、随机串已包含在上述回传块内(与 Step 2 待签名串中一致),开发者无需单独回贴 JSON 中的timestamp/nonce_str字段。
---
Step 4: 发起查询(api call)
4.1 证书商户号校验(方式 B)
比对 Step 1 收集的商户号与 API 证书中的商户号 是否一致:merchant 模式对 mchid;partner 模式对 sp_mchid(即服务商商户号)。
4.2 发起 api call
--mode、--params(传参方式见「CLI 与参数约定」)与 Step 1 / Step 2 相同;--mchid、--serial_no、--timestamp、--nonce_str、--signature 一律取自 Step 3 回传。响应为 JSON,解析 status 与 body 分析结果。
wechatpay-dev-cli api call <接口ID> \
--mode <merchant|partner> \
--mchid "<Authorization 中的 mchid>" \
--params '<JSON>' \
--serial_no "<Step3>" \
--timestamp "<Step3>" \
--nonce_str "<Step3>" \
--signature "<Step3>"wechatpay-dev-cli 使用说明
本 Skill在 「能力 4 → APIv3 接口动态排障」 分支依赖 wechatpay-dev-cli。产品选型、示例代码、文档问答、接入质检 不需要 安装 CLI。
检测 CLI 是否可用
进入 APIv3接口动态排障 之前,在终端执行:
wechatpay-dev-cli --version正常时应输出版本号,例如:1.0.0 能跑通 --version 才说明 Node 环境、wechatpay-dev-cli 环境已经准备好;仅知道 wechatpay-dev-cli 这个命令名存在不够。
---
安装
依赖:Node.js ≥ 20(包名 @tenpay/wechatpay-dev-cli)。
npm install -g @tenpay/wechatpay-dev-cli
wechatpay-dev-cli --version---
使用时的常见问题
| 现象 | 可能原因 | 处理 |
|---|---|---|
wechatpay-dev-cli: command not found | 未安装或 npm 全局 bin 不在 PATH | npm install -g @tenpay/wechatpay-dev-cli,确认 npm config get prefix/bin 已加入 PATH |
npm: command not found | 未装 Node | 安装 Node.js 20+ |
安装成功但 --version 仍报错 | Node 版本过低 | node --version 需 ≥ 20 |
Windows 下 api build 参数异常 | PowerShell 剥引号 | 排障文档要求用 @$env:TEMP\xxx.json 传 --params,勿 inline 复杂 JSON |
| 401 SIGN_ERROR | 非安装问题 | 回到排障文档 Step 2/3,检查 signMessage 是否原样签名 |
基础概念及业务介绍
一、角色(接入模式)
微信支付的接入模式主要有:普通商户、服务商、机构、渠道商等。本Skill关注普通商户和服务商(含服务商体系下的特约商户)。
1.1 普通商户
自己在微信支付官网申请商户号(mchid),自己对接微信支付 API 完成收款。
1.2 服务商
在微信支付官网申请服务商商户号(sp_mchid)的第三方,负责为旗下子商户/特约商户提供技术接入和支付服务。服务商自身不收款,通过服务商模式 API 代特约商户发起收款、退款等操作,请求中需同时传 sp_mchid 和 sub_mchid。
1.3 特约商户
由服务商通过「特约商户进件」接口代为入驻微信支付的商户,入驻后获得独立的子商户号(sub_mchid),交易资金直接结算到特约商户自己的账户,不经过服务商。
API 对接有两种方式:
- 服务商代调(常见):由服务商使用服务商模式 API 代其发起收款、退款等操作
- 特约商户自调:普通服务商模式下(非电商、银行机构)的特约商户,可以登录商户平台申请 API 证书,用自己的
sub_mchid直接调用普通商户模式的接口
---
二、API 版本(V2 与 V3)
微信支付有 V2 和 V3 两个 API 版本。V2 存量维护,不再新增功能,V3是主力版本。默认使用 V3,仅当产品/接口只有 V2 版本时才用 V2。若用户未指定版本,一律按 V3 提供,严禁主动推荐或引导用户使用 V2。
---
三、产品版本偏好
除 API 版本(V2/V3)外,部分产品本身也存在新旧版本迭代(如商家转账升级版、移动医保支付 2.0 等)。当同一产品存在多个版本时,默认按最新版本提供文档;若用户使用旧版本,提示有新版本可升级。
---
四、微信支付与开放平台的关系
开发者通过公众平台(mp.weixin.qq.com)创建公众号、小程序,通过开放平台(open.weixin.qq.com)创建移动应用、网站应用,每个应用都有一个 appid。微信支付与这些平台的关系主要体现在:
- appid 绑定:商户号(
mchid)必须与至少一个appid绑定后才能发起交易 - openid 用户标识:微信支付接口中通过
openid识别用户身份,openid是用户在某个appid下的唯一标识,同一用户在不同appid下的openid不同
4.1 mchid 与 appid 的关系
mchid与 appid 是多对多的绑定关系:一个 mchid 可绑定多个 appid,绑定后即可使用该 appid 发起支付。绑定在商户平台「APPID 授权管理」中操作,需 appid 所在平台确认授权,且绑定后不支持解绑。
4.2 appid 与 openid 的关系
openid 是用户在某个 appid 下的唯一标识,这是理解微信支付用户身份的关键:
- 同一个微信用户,在不同 appid 下有不同的 openid
| 维度 | 普通商户 | 服务商 |
|---|---|---|
| appid | appid(商户自己的应用) | sp_appid(服务商应用)+ sub_appid(子商户应用,选填) |
| openid | openid(用户在商户 appid 下的标识) | sp_openid 或 sub_openid,二选一,取决于用哪个 appid 做的用户授权 |
如何理解用户问题
概述
对用户输入做润色、纠错、指代消解,得到更清晰、完整的问题表述,全程保持用户原意,不增删关键事实。
原则
- 忠实:不改变用户要问什么。
- 清晰:消除歧义,多轮对话补全省略信息。
- 节制:能推断的不反复追问;不过度改写。
多轮:指代消解
当前问题依赖前文时:
- 将「它 / 这个接口 / 上面那种」等还原为具体对象(产品名、接口名、错误现象等)。
- 继承前文已确定的商户角色、API 版本、业务场景,避免答非所问。
- 消解后仍须遵守「保留的技术原文」规则。
单轮:润色与纠错
目标:把零散、口语、含糊的表述整理成适合检索的规范问句。
- 明确意图:补全隐含条件,描述清楚「要什么、在什么场景下」。
- 规范用语:纠正错别字与明显笔误;口语可改为书面语,但不改变技术含义。
- 去冗余:删掉重复寒暄,保留与问题相关的关键实体与动作。
保留的技术原文
润色只整理表述,不改动用户已给出的技术细节。下列片段须原样保留(不得改写、替换或「纠正」字面内容,即使看起来像笔误);仅可在其前后补充说明性文字:
- 官方文档或接口 URL(含文档
doc_id) - 接口路径与 HTTP Method
- 错误码、错误报文片段
- 字段名、参数名、枚举值
- 支付产品名、API 证书等官方术语
微信支付接入质量检查清单(通用)
适用范围:所有微信支付业务的通用质检框架——境内基础支付、合单支付、境外微信支付、医保支付、委托代扣、微信支付分、商品券、商家券、刷脸支付等。
角色设定:金融支付系统技术专家
‼️ 本节角色、铁律和问题雷达是质检的全部驱动力,必须内化后再审代码。
你是金融支付系统技术专家,全栈工程师出身,亲手写过从前端收银台到后端交易引擎的全链路代码。你主导过千万级用户规模的国民级支付系统架构设计,从零搭建过高并发交易平台。你熟悉主流支付平台的接入规范与安全体系,对 API 签名验签机制、异步回调通知处理、资金流对账有丰富的实战经验。你对代码质量有极强的直觉,尤其对资金链路上的异常处理缺失高度警觉。
你对支付系统的要求极高:接口交互必须有完善的异常处理和兜底方案,资金操作必须可追溯、可对账,所有外部输入必须经过校验才能进入业务逻辑。
铁律
铁律一:高可用(99.9999%)
要求:系统可用性 99.9999%(六个 9),即每一百万次请求中最多允许一次失败。资金链路上不允许单点故障,每一个外部调用都必须有超时、重试和降级方案。
检查直觉:
- 调用微信支付 API 超时了,代码会自动重试还是直接报错?
- 重试时会不会导致重复操作?
- 微信异步通知一直没来,系统有没有定时主动查询服务端状态?
- 用户快速点击两次提交,会不会创建两笔业务单?
铁律二:资金安全(一分钱都不能错)
要求:金额计算必须使用整数(单位:分),杜绝浮点精度丢失。每一笔资金变动(支付、退款、分账、扣款、出款)都必须有据可查,系统必须主动通过对账机制发现差异。
检查直觉:
- 金额字段的类型是
int/long还是double/float? - 涉及金额累加 / 累减的地方,有没有用本地账本校验上限?
- 系统有没有每天自动拉取微信账单和本地业务流水做比对?
铁律三:零信任(不信任任何未经验证的外部数据)
要求:微信异步通知、前端 / 客户端传入的参数、缓存中的数据,在进入业务逻辑前必须经过验证;未验证的输入一律视为不可信。
检查直觉:
- 收到异步通知后,代码是先验签还是直接解析 body 处理业务?
- 写入微信 API 的金额 / 商户号 / 用户标识等关键字段,是后端查的还是直接用前端传值?
- 通知中的关键字段有没有和本地数据做比对?
- 私钥是通过环境变量加载的,还是硬编码在代码里?
---
检查方法
1. 扫代码 — 快速扫描代码,按问题雷达定位高风险区域 2. 追链路 — 沿业务流完整走一遍:发起请求 → 服务端处理 → 异步通知 → 主动查询 → 后续操作 → 对账,任何断点都是事故点 3. 做预演 — 对每个关键节点问"如果这里故障了 / 超时了 / 被攻击了 / 来了两次,会怎样?"
输出要求:发现问题必须给出修复方向,不能只说"有风险";必须基于代码事实,不基于猜测;结果按 🔴🟡🟠 分级,致命问题置顶。
通用问题雷达
| 模块 | 检查项 | 必要性 | 说明 |
|---|---|---|---|
| 签名 | 异步通知先验签再处理业务 | 🔴 致命 | 收到通知时代码是先 verify_sign(headers, body) 还是直接 JSON.parse(body)?验签失败必须立即 return,禁止继续业务逻辑 |
| 签名 | 验签失败必须返回 4xx/5xx,并正确处理 SIGNTEST 探测流量 | 🔴 致命 | 验签失败返回 200 等于"通知成功",微信不会重试;微信会下发签名错误的探测流量(前缀 WECHATPAY/SIGNTEST/)测试商户是否正确验签,返回 200 即视为安全隐患 |
| 安全 | 客户端 / APP / H5 禁出现 API 私钥 / 证书 / APIv3 密钥 | 🔴 致命 | grep 私钥文件名 / 商户号 / APIv3 密钥是否出现在前端 JS、APK 反编译产物、H5 / 小程序页面里;私钥应从环境变量或 KMS 加载 |
| 安全 | 资金 / 关键字段一律以后端为准,禁信前端传值 | 🔴 致命 | 调用微信 API 的金额、商户号、用户标识、业务类型等关键字段必须从可信后端数据源读取;前端传入仅作为引导,不能直接落库或入参 |
| 安全 | 敏感字段(姓名 / 身份证 / 手机号 / 邮箱 / 银行账号)用平台公钥加密 | 🔴 致命 | 进件、开户意愿、订单转账、用户信息上报等业务的敏感字段必须用微信支付公钥加密,并在 Header 携带 Wechatpay-Serial;明文上送即合规风险 |
| 幂等 | 调用重试 + 异步通知都必须做业务幂等 | 🟡 必须 | 同一笔业务被多次触达时结果必须一致:① 调微信 API 网络异常重试时复用原业务单号幂等;② 异步通知多次收到时以业务单号 + 状态机锁保证幂等。避免重复操作 / 重复出资金 |
| 兜底 | 异步通知缺失时有主动查询兜底 | 🟡 必须 | 关键链路(支付、退款、扣款、分账等)必须有定时任务主动查询服务端状态作为兜底,禁止仅依赖异步通知 |
| 打印日志 | 关键链路打印 Request-Id | 🟠 建议 | 调微信 API 时把响应 Header 中的 Request-Id 写入业务日志,回调 / 报错时凭 Request-Id 可让微信侧快速定位到服务日志,是排障最高效的线索 |
文档检索与问答
在 `<SKILL目录>/assets/微信支付官网文档/` 知识库内,先用关键词 全局 `Grep` 探路,根据命中路径与用户语义 再缩小范围 并 Read 精读;`<SKILL目录>/assets/wechatpay-docs-guide.md` 与角色/版本判断用于辅助校验,不作为锁定目录的唯一依据。
目录索引的定位
三级目录树见 `<SKILL目录>/assets/wechatpay-docs-guide.md`。
不要在首次检索前仅凭索引或主观推断就锁定单一目录前缀——索引一旦选错,后续 Grep 会系统性漏检。
索引适用于:全局 Grep 之后,用命中文件的路径分布 核对 是否落在预期的 APIv2/APIv3、普通商户/合作伙伴 树下;或在命中分散、语义仍模糊时 辅助 选定下一轮聚焦目录。
回答所依据的正文须全部来自 `<SKILL目录>/assets/微信支付官网文档/` 内经 Read 读取的 Markdown。
歧义时的默认优先级
当无法从用户问题判断 API 版本或商户角色,且 Grep 命中显示同一主题在多个分支均有文档时,精读与作答按下列默认优先级选择(用户已明确给出的版本/角色信号始终优先,不得覆盖):
| 歧义维度 | 用户未给判断依据,且 v2/v3 或两种角色均有相关命中 | 默认优先 |
|---|---|---|
| API 版本 | APIv2/... 与 APIv3/... 下均有同等相关文档 | APIv3 |
| 商户角色 | 普通商户/... 与 合作伙伴/... 下均有同等相关文档 | 普通商户 |
合作伙伴子场景(用户已判定或命中收敛到 合作伙伴/,但未声明具体合作伙伴类型):
| 歧义维度 | 用户未给判断依据,且两侧均有相关命中 | 默认优先 |
|---|---|---|
| 合作伙伴类型 | 平台收付通-电商交易解决方案/ 与 合作伙伴/ 下其他路径(如 支付产品/、子商户管理/ 等)均有同等相关文档 | 普通合作伙伴(非收付通路径) |
用户明确提到「收付通」「平台收付通」「电商收付通」「电商交易解决方案」等信号时,优先对应收付通分支。
应用方式:第三步缩小范围时,在仍无法凭用户表述区分的情况下,将聚焦 Grep / Read 优先落在 APIv3 与/或 普通商户 对应路径;已收敛到 合作伙伴/ 但未声明合作伙伴类型时,优先落在普通合作伙伴路径(非 平台收付通-电商交易解决方案/)。各层歧义下,非默认分支仅当默认侧文档明显不覆盖用户问题(零命中或语义不符)时再查阅。作答时以「📋 相关文档」中的路径体现所选版本与角色即可,无需在注意事项中重复说明默认优先级。
核心工作流(必须按顺序)
建议顺序:理解问题并拟定检索词 → 全局 `Grep` 探路 → 结合命中与语义缩小范围 → 聚焦 `Grep` / `Read` → 作答。
第一步:理解用户问题并拟定检索词
按 `<SKILL目录>/references/如何理解用户问题.md` 润色、纠错、指代消解。
从问题中抽出 1~3 个 用于首轮 Grep 的 pattern:优先用户原文中的 URL、接口路径、错误码、字段名、产品官方名等;pattern 须具体、可命中,避免过宽(如单独搜「支付」)或过窄导致零命中。用户给出官网链接或文档 ID 时,从中提取纯数字 doc_id 作为 Grep 的 pattern(文件名和 front matter 中均包含 doc_id,数字 ID 可精确命中)。
若用户已明确角色或 API 版本,可记录下来供第三步对照,但不在此步据此限制 `Grep` 目录。
第二步:全局 Grep 探路
在 `<SKILL目录>/assets/微信支付官网文档/` 全树下,用第一步的 pattern 做 Grep(可换 pattern 各搜一轮)。
关注:
- 命中文件的路径和文件名:路径自带中文目录名和文档标题(如
支付产品/JSAPI支付/开发指引-4012791870.md),直接判断是否与问题相关 - 命中片段语义:是否与用户要问的内容一致
- 命中数量:过多则换更具体的
pattern;为零则放宽或换同义官方用词再搜一轮 - 路径分布:是否集中在某一
APIv2/APIv3、普通商户/合作伙伴分支,还是跨多个分支
本步目的是用真实命中校准检索方向,而不是先猜目录。
当用户问的是产品分类概览(如「营销产品都有哪些」「V3 普通商户有几类产品」),可用 Glob 列出对应目录的子目录/文件名,直接从路径获取答案,不必逐篇 Grep。
第三步:结合命中与用户语义缩小范围
综合第二步结果与用户问题含义:
1. 以命中路径为主:路径明显相关(目录名、文件名与问题直接对应)时,直接进入第四步精读,不需要额外读 front matter 确认。若命中跨角色/版本,结合用户表述与 `<SKILL目录>/references/基础概念及业务介绍.md`,筛掉明显无关分支,勿凭猜测丢弃仍有相关命中的分支。 2. 歧义默认优先级:若用户问题无法判断 API 版本或商户角色,且相关文档在 `APIv2`/`APIv3` 或 `普通商户`/`合作伙伴` 两侧均有,按 [歧义时的默认优先级](#歧义时的默认优先级) 优先收敛到 APIv3、普通商户 路径再精读;已收敛到 `合作伙伴/` 但未声明合作伙伴类型时,按同节合作伙伴子场景优先普通合作伙伴(非收付通)路径。 3. 索引作辅助:当命中分散或难以取舍时,再 `Read` <SKILL目录>/assets/wechatpay-docs-guide.md 目录树,对照叶节点标注判断哪条路径更贴题;若索引判断与 `Grep` 命中冲突,以与问题更相关的命中文件为准**。
筛选目标:锁定最相关的 1~3 篇文档。
若缩小后仍无足够相关正文:回到第一步调整 pattern,或回到第二步对候选分支 分前缀再各做一轮 `Grep`。
第四步:聚焦 Grep / Read(仅 1~3 篇)
1. 在第三步确定的前缀下(若第三步未收敛出单一前缀,则对仍有相关性的少数前缀分别检索),用更精确的 pattern 做聚焦 Grep;pattern 中用户已给出的技术片段须与原文一致(同第一步)。 2. 使用 Read 读取最相关的 1~3 篇 .md,从文件头开始读(行 1 起),可一并获取 front matter 中的 url,无需在第五步单独再读。可用 limit 控制篇幅,不要大面积通读,只读与问题直接相关的部分。 3. 同一文档 ID 可能存在 -请求示例-java/-请求示例-go/-请求示例-curl 等代码示例副本,接口说明以不含语言后缀的主文件为准;示例代码文件仅在回答需要代码示例时 Read。
对比类问题(如「V2 和 V3 的分账有什么区别」)可在每个分支各取 1 篇,总数仍控制在 1~3 篇内。
正文中的链接处理:
- 微信支付官方文档链接(
pay.weixin.qq.com文档站):优先在 `<SKILL目录>/assets/微信支付官网文档/` 内用Grep(URL 路径片段、文档标题、doc路径、文档 ID 等)定位对应.md再Read。若在知识库内未找到对应或等价文档(已用链接中的路径片段、标题等检索仍无命中),可对该 URL 使用WebFetch获取正文作为补充依据。 - 其他链接:不要擅自使用
WebFetch;先询问用户是否需要联网打开该链接;仅当用户明确同意后再使用WebFetch等联网工具。
第五步:基于正文生成回答
简洁作答(必守):只答用户所问,篇幅与问题复杂度匹配;先给结论或做法,再补必要细节。
- 禁止写入答案:检索过程、无关背景科普、用户未问及的接口/产品/字段、大段摘抄文档、为显得完整而补充的边缘信息。
- 默认不写:用户未追问则不主动展开延伸话题;「⚠️ 注意事项」仅在确有踩坑风险且与问题直接相关时输出。
若第四步 Read 时已从文件头读起,front matter 中的 url 已在手;否则对实际引用的文档补读 front matter(offset 1、limit 5),用于「📋 相关文档」。
- 结论须可溯源:来自知识库内具体文件(路径自
<SKILL目录>/assets/微信支付官网文档/起算,须一致、可定位),或来自知识库未收录的pay.weixin.qq.com官方页经WebFetch获取的正文(作答中注明来源 URL)。 - 禁止凭模型记忆编造接口、字段与错误码;引用的段落须全部来自知识库
Read或上述允许的WebFetch结果。
「📋 相关文档」段落(必须遵守)
仅列出你在答案中实际引用过的知识库 .md(来自 <SKILL目录>/assets/微信支付官网文档/)。
- 条数上限:最多 3 条(1~3 条均可)。只列与问题最直接相关的文档,按相关度从高到低排列;不要为凑数或「看起来完整」而多列。
- 同一顺序:与正文引用顺序一致。
- 每条格式:左侧为从
APIv2/或APIv3/起算的本地文件相对路径(不写assets/前缀);右侧为front matter中url字段去掉末尾 `.md`(如url为https://pay.weixin.qq.com/doc/v3/merchant/4012062524.md,则展示https://pay.weixin.qq.com/doc/v3/merchant/4012062524);无url则写 `(本文件 front matter 无 url,勿编造)`,禁止自行拼接链接。 - 自检:「相关文档」不超过 3 条;每条链接均来自对应
.md的front matterurl字段(去掉.md后缀)。
按以下格式组织回答:
## [简短答案,直接回答用户问题]
### 📋 相关文档
- **`{本地文件路径}`**:`{front matter url 去掉 .md}`
- ...(最多 3 条)
### ⚠️ 注意事项
- [注意点]注意:模板中的「📋 相关文档」和「⚠️ 注意事项」标题须原样输出,但不要输出规则说明文字(如条数、格式要求等)。「⚠️ 注意事项」仅在确有需要提醒时才加,无则省略整个段落。
硬性约束
1. 探路优先:首轮须在 <SKILL目录>/assets/微信支付官网文档/ 全树(或用户已给出的 URL/路径所能定位的最小合理范围)上 Grep,不得在未看命中分布的情况下仅凭索引锁定单一前缀。 2. 路径判断优先:文件名和目录已包含中文标题,路径明显相关时直接精读,不要批量读 front matter 做筛选。 3. 检索词忠实:Grep 的 pattern 须保留用户原文中的 URL、接口路径、错误码、字段名等(同第一步)。 4. 精读节制:只读最相关的 1~3 篇正文,不要大面积扫描;缩小范围后的 Grep/Read 应落在第三步确定的前缀或少数候选前缀内。 5. 引用前取 `url`:最终引用的文档须读 front matter 取 url,禁止自行拼接链接。 6. 歧义默认:版本/角色无法从用户问题判断且双侧均有文档时,精读与作答优先 `APIv3`、`普通商户`(见上文专节);已收敛到 合作伙伴/ 时,未声明合作伙伴类型则优先普通合作伙伴(非 平台收付通-电商交易解决方案/);用户已明确的信号优先于本规则。 7. 禁止编造:接口、字段、错误码须来自知识库 Read 结果,不凭模型记忆编造。 8. 知识库未覆盖时如实告知:若经充分检索仍未找到相关文档,应明确告知用户该问题超出当前知识库覆盖范围,不要硬凑答案。 9. 简洁作答:答案只含直接回答用户问题所需的信息;用户未问的不展开,不堆砌无关内容(见第五步「简洁作答」)。
示例
用户:「合作伙伴 APIv3 合单 JSAPI 下单里 sub_mchid 怎么传?」
1. 第一步:拟定 pattern:sub_mchid、合单、JSAPI(保留原文字段名)。 2. 第二步:在 <SKILL目录>/assets/微信支付官网文档/ 全树 Grep,观察命中路径(如 APIv3/合作伙伴/支付产品/JSAPI合单支付/...),从文件名和目录直接判断相关性。 3. 第三步:结合「合作伙伴」「APIv3」与命中分布,将精读范围收敛到 APIv3/合作伙伴/支付产品;路径明显相关,不需要额外读 front matter 确认。 4. 第四步:在该前缀下聚焦 Grep,Read 最相关的 1~2 篇 .md;文中 pay.weixin.qq.com 链接优先在知识库内追链,知识库无对应页时可 WebFetch。 5. 第五步:读取引用文档的 front matter 取 url(去掉 .md);作答;「📋 相关文档」不超过 3 条。
{
"LAST_CHECK_TIME": "2026-06-12T12:06:24+08:00",
"LAST_UPDATE_TIME": "2026-06-12T12:06:28+08:00",
"REMOTE_LAST_MODIFIED": "thu, 11 jun 2026 21:31:18 gmt",
"REMOTE_ETAG": "\"60ff836e1d9eec113bcf8a3726e22ec5\""
}
#!/bin/bash
#
# 微信支付 APIv3 - P12 证书信息提取与签名工具
#
# 用法(推荐,签名时刻生成 TIMESTAMP / NONCE_STR):
# bash extract_and_sign.sh --filePath <P12> [--password <密码>] \
# --method GET --url '/v3/pay/transactions/id/xxx?mchid=yyy'
#
# 用法(可选,自行提供时间戳与随机串):
# bash extract_and_sign.sh --filePath <P12> --method GET --url '...' \
# --timestamp <秒级时间戳> --nonce_str <32位随机串>
#
# 用法(兼容,传入完整待签名串):
# bash extract_and_sign.sh --filePath <P12> --signString 'GET\n/...\n...\n\n'
set -euo pipefail
P12_FILE=""
P12_PASSWORD=""
HTTP_METHOD=""
REQUEST_URL=""
TIMESTAMP_IN=""
NONCE_IN=""
SIGN_STRING=""
usage() {
echo "用法: bash extract_and_sign.sh --filePath <P12文件路径> [--password <P12密码>] \\"
echo " (--method <HTTP方法> --url '<请求URL路径+query>') | --signString '<待签名串>'"
echo ""
echo "参数说明:"
echo " --filePath apiclient_cert.p12 文件路径(支持 file:///... 或 @/path/...)"
echo " --password P12 密码(可选;未传时可能从 URL 中的 mchid/sp_mchid 尝试)"
echo " --method HTTP 方法,如 GET(与 --url 搭配使用)"
echo " --url 请求 URL(含 path 与 query,以 / 开头)"
echo " --timestamp 秒级时间戳(可选;未传则在签名时自动生成)"
echo " --nonce_str 随机串(可选;未传则在签名时自动生成)"
echo " --signString 完整待签名串(兼容旧用法;含 \\\\n 转义换行)"
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--filePath)
P12_FILE="${2:-}"
shift 2
;;
--password)
P12_PASSWORD="${2:-}"
shift 2
;;
--method)
HTTP_METHOD="${2:-}"
shift 2
;;
--url)
REQUEST_URL="${2:-}"
shift 2
;;
--timestamp)
TIMESTAMP_IN="${2:-}"
shift 2
;;
--nonce_str)
NONCE_IN="${2:-}"
shift 2
;;
--signString)
SIGN_STRING="${2:-}"
shift 2
;;
-h|--help)
usage
;;
*)
echo "错误: 未知参数: $1"
usage
;;
esac
done
if [ -z "$P12_FILE" ]; then
usage
fi
if [ -z "$SIGN_STRING" ]; then
if [ -z "$HTTP_METHOD" ] || [ -z "$REQUEST_URL" ]; then
echo "错误: 请提供 --method 与 --url,或提供 --signString"
usage
fi
if [[ "$REQUEST_URL" != /* ]]; then
echo "错误: --url 必须以 / 开头(path + query)"
exit 1
fi
if [ -z "$TIMESTAMP_IN" ]; then
TIMESTAMP_IN="$(date +%s)"
fi
if [ -z "$NONCE_IN" ]; then
NONCE_IN="$(openssl rand -hex 16 | tr '[:lower:]' '[:upper:]')"
fi
SIGN_STRING="${HTTP_METHOD}"$'\n'"${REQUEST_URL}"$'\n'"${TIMESTAMP_IN}"$'\n'"${NONCE_IN}"$'\n\n'
elif [ -z "$TIMESTAMP_IN" ] || [ -z "$NONCE_IN" ]; then
# 从完整待签名串解析时间戳与随机串(第 3、4 行)
[ -z "$TIMESTAMP_IN" ] && TIMESTAMP_IN=$(printf '%b' "$SIGN_STRING" | sed -n '3p')
[ -z "$NONCE_IN" ] && NONCE_IN=$(printf '%b' "$SIGN_STRING" | sed -n '4p')
fi
# 兼容 file:/// file: @ 前缀
if [[ "$P12_FILE" == file://* ]]; then
P12_FILE="${P12_FILE#file://}"
if [[ "$P12_FILE" != /* ]]; then
P12_FILE="/$P12_FILE"
fi
elif [[ "$P12_FILE" == file:* ]]; then
P12_FILE="${P12_FILE#file:}"
fi
if [[ "$P12_FILE" == @* ]]; then
P12_FILE="${P12_FILE#@}"
fi
if [[ "$P12_FILE" == "/path/to/"* ]] || [[ "$P12_FILE" == *"\\path\\to\\"* ]] || [[ "$P12_FILE" == *":\\path\\to\\"* ]]; then
echo "错误: 请将 --filePath 参数替换为你本地 P12 证书的真实路径"
exit 1
fi
if [ ! -f "$P12_FILE" ]; then
echo "错误: P12 文件不存在: $P12_FILE"
exit 1
fi
_is_pem_like_file() {
local file="$1"
local ext first
ext="${file##*.}"
ext=$(printf '%s' "$ext" | tr '[:upper:]' '[:lower:]')
case "$ext" in
pem|crt|cer|key) return 0 ;;
esac
first=$(head -n 1 "$file" 2>/dev/null || true)
[[ "$first" == -----BEGIN* ]]
}
_fail_wrong_cert_format() {
local file="$1"
echo "错误: --filePath 指向的是 PEM/证书文件($(basename "$file")),本脚本仅支持 PKCS#12 格式的 apiclient_cert.p12"
echo "提示: 请将 --filePath 改为你本地的 apiclient_cert.p12 路径(证书压缩包内通常同时提供 .p12 与 .pem,请选用 .p12)"
exit 1
}
if _is_pem_like_file "$P12_FILE"; then
_fail_wrong_cert_format "$P12_FILE"
fi
_guess_p12_password() {
local hint="$1"
local pwd
pwd=$(printf '%s' "$hint" | sed -n 's/.*[?&]mchid=\([^&]*\).*/\1/p' | head -n 1)
if [ -z "$pwd" ]; then
pwd=$(printf '%s' "$hint" | sed -n 's/.*[?&]sp_mchid=\([^&]*\).*/\1/p' | head -n 1)
fi
printf '%s' "$pwd"
}
# 密码候选:优先从 URL 提取 mchid / sp_mchid
PWD_HINT="${REQUEST_URL:-$SIGN_STRING}"
LEGACY_FLAG=""
PASSIN="pass:$P12_PASSWORD"
if openssl pkcs12 -in "$P12_FILE" -clcerts -nokeys -passin "$PASSIN" -legacy >/dev/null 2>&1; then
LEGACY_FLAG="-legacy"
elif ! openssl pkcs12 -in "$P12_FILE" -clcerts -nokeys -passin "$PASSIN" >/dev/null 2>&1; then
if [ -z "$P12_PASSWORD" ]; then
CAND_PWD="$(_guess_p12_password "$PWD_HINT")"
if [ -n "$CAND_PWD" ]; then
P12_PASSWORD="$CAND_PWD"
PASSIN="pass:$P12_PASSWORD"
if openssl pkcs12 -in "$P12_FILE" -clcerts -nokeys -passin "$PASSIN" -legacy >/dev/null 2>&1; then
LEGACY_FLAG="-legacy"
elif ! openssl pkcs12 -in "$P12_FILE" -clcerts -nokeys -passin "$PASSIN" >/dev/null 2>&1; then
echo "错误: 无法读取 P12 文件。可能需要 P12 密码,请在命令中追加: --password \"<P12密码>\"(常见为商户号)"
exit 1
fi
else
echo "错误: 无法读取 P12 文件。可能需要 P12 密码,请在命令中追加: --password \"<P12密码>\"(常见为商户号)"
exit 1
fi
else
if _is_pem_like_file "$P12_FILE"; then
_fail_wrong_cert_format "$P12_FILE"
fi
echo "错误: 无法读取 P12 文件,请检查 --password 是否为 P12 密码(常见为商户号)"
exit 1
fi
fi
CERT_PEM=$(openssl pkcs12 -in "$P12_FILE" -clcerts -nokeys -passin "$PASSIN" $LEGACY_FLAG 2>/dev/null)
SERIAL=$(echo "$CERT_PEM" | openssl x509 -serial -noout 2>/dev/null | sed 's/serial=//')
if [ -z "$SERIAL" ]; then
echo "错误: 无法提取证书序列号"
exit 1
fi
SUBJECT=$(echo "$CERT_PEM" | openssl x509 -subject -noout -nameopt RFC2253 2>/dev/null)
MCHID=$(echo "$SUBJECT" | sed -n 's/.*CN=\([^,]*\).*/\1/p')
if [ -z "$MCHID" ]; then
MCHID="(无法从证书 CN 字段提取,请手动确认)"
fi
PRIVKEY=$(openssl pkcs12 -in "$P12_FILE" -nocerts -nodes -passin "$PASSIN" $LEGACY_FLAG 2>/dev/null)
if [ -z "$PRIVKEY" ]; then
echo "错误: 无法提取私钥"
exit 1
fi
SIGNATURE=$(printf "%b" "$SIGN_STRING" | \
openssl dgst -sha256 -sign <(echo "$PRIVKEY") 2>/dev/null | \
openssl base64 -A)
if [ -z "$SIGNATURE" ]; then
echo "错误: 签名失败"
exit 1
fi
echo "---------- 签名结果开始 ----------"
echo "API 证书序列号(serial_no): $SERIAL"
echo "时间戳(timestamp): $TIMESTAMP_IN"
echo "随机串(nonce_str): $NONCE_IN"
echo "API 证书中的商户号: $MCHID"
echo "签名值(signature): $SIGNATURE"
echo "---------- 签名结果结束 ----------"
#
# 微信支付 APIv3 - P12 证书信息提取与签名工具 (Windows PowerShell 版)
#
# 推荐用法(签名时刻生成 TIMESTAMP / NONCE_STR):
# powershell -ExecutionPolicy Bypass -File extract_and_sign.ps1 `
# -FilePath "apiclient_cert.p12" -Method GET -Url "/v3/pay/transactions/id/xxx?mchid=yyy"
#
param(
[Parameter(Mandatory=$true, HelpMessage="Path to apiclient_cert.p12")]
[string]$FilePath,
[Parameter(Mandatory=$false, HelpMessage="P12 password (optional)")]
[string]$Password = "",
[Parameter(Mandatory=$false, HelpMessage="HTTP method, e.g. GET")]
[string]$Method = "",
[Parameter(Mandatory=$false, HelpMessage="Request URL path+query, starts with /")]
[string]$Url = "",
[Parameter(Mandatory=$false, HelpMessage="Unix timestamp seconds (optional)")]
[string]$Timestamp = "",
[Parameter(Mandatory=$false, HelpMessage="Nonce string (optional)")]
[string]$NonceStr = "",
[Parameter(Mandatory=$false, HelpMessage="Full sign string; use \\n for newlines")]
[string]$SignString = ""
)
$ErrorActionPreference = "Stop"
function Get-MchidFromHint([string]$hint) {
if ($hint -match '[\?&]mchid=([^&\\n]+)') { return $Matches[1] }
if ($hint -match '[\?&]sp_mchid=([^&\\n]+)') { return $Matches[1] }
return $null
}
if ([string]::IsNullOrEmpty($SignString)) {
if ([string]::IsNullOrEmpty($Method) -or [string]::IsNullOrEmpty($Url)) {
Write-Host "错误: 请提供 -Method 与 -Url,或提供 -SignString" -ForegroundColor Red
exit 1
}
if (-not $Url.StartsWith("/")) {
Write-Host "错误: -Url 必须以 / 开头(path + query)" -ForegroundColor Red
exit 1
}
if ([string]::IsNullOrEmpty($Timestamp)) {
$Timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
}
if ([string]::IsNullOrEmpty($NonceStr)) {
$bytes = New-Object byte[] 16
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
$NonceStr = ([BitConverter]::ToString($bytes) -replace '-', '')
}
$SignString = "$Method`n$Url`n$Timestamp`n$NonceStr`n`n"
} elseif ([string]::IsNullOrEmpty($Timestamp) -or [string]::IsNullOrEmpty($NonceStr)) {
$lines = ($SignString -replace '\\n', "`n") -split "`n"
if ($lines.Count -ge 4) {
if ([string]::IsNullOrEmpty($Timestamp)) { $Timestamp = $lines[2] }
if ([string]::IsNullOrEmpty($NonceStr)) { $NonceStr = $lines[3] }
}
}
if ($FilePath.StartsWith("file://")) {
$FilePath = $FilePath.Substring(7)
if (-not $FilePath.StartsWith("/")) { $FilePath = "/" + $FilePath }
} elseif ($FilePath.StartsWith("file:")) {
$FilePath = $FilePath.Substring(5)
}
if ($FilePath.StartsWith("@")) { $FilePath = $FilePath.Substring(1) }
if ($FilePath -like "/path/to/*" -or $FilePath -like "*\path\to\*" -or $FilePath -like "*:\path\to\*") {
Write-Host "错误: 请将 -FilePath 参数替换为你本地 P12 证书的真实路径" -ForegroundColor Red
exit 1
}
if (-not (Test-Path $FilePath)) {
Write-Host "错误: P12 文件不存在: $FilePath" -ForegroundColor Red
exit 1
}
function Test-PemLikeFile([string]$path) {
$ext = [System.IO.Path]::GetExtension($path).ToLowerInvariant()
if ($ext -in '.pem', '.crt', '.cer', '.key') { return $true }
$first = Get-Content -Path $path -TotalCount 1 -ErrorAction SilentlyContinue
return ($first -like '-----BEGIN*')
}
function Write-WrongCertFormatError([string]$path) {
$name = [System.IO.Path]::GetFileName($path)
Write-Host "错误: -FilePath 指向的是 PEM/证书文件($name),本脚本仅支持 PKCS#12 格式的 apiclient_cert.p12" -ForegroundColor Red
Write-Host "提示: 请将 -FilePath 改为你本地的 apiclient_cert.p12 路径(证书压缩包内通常同时提供 .p12 与 .pem,请选用 .p12)" -ForegroundColor Yellow
exit 1
}
if (Test-PemLikeFile $FilePath) {
Write-WrongCertFormatError $FilePath
}
$P12FullPath = (Resolve-Path $FilePath).Path
$pwdHint = "$Url$SignString"
try {
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(
$P12FullPath, $Password,
[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable
)
} catch {
if ([string]::IsNullOrEmpty($Password)) {
$cand = Get-MchidFromHint $pwdHint
if ($cand) {
try {
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(
$P12FullPath, $cand,
[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable
)
$Password = $cand
} catch {
Write-Host '错误: 无法加载 P12 文件。可能需要 P12 密码,请在命令中追加: -Password "你的P12密码"(常见为商户号)' -ForegroundColor Red
exit 1
}
} else {
Write-Host '错误: 无法加载 P12 文件。可能需要 P12 密码,请在命令中追加: -Password "你的P12密码"(常见为商户号)' -ForegroundColor Red
exit 1
}
} else {
if (Test-PemLikeFile $FilePath) {
Write-WrongCertFormatError $FilePath
}
Write-Host "错误: 无法加载 P12 文件,请检查 -Password 是否为 P12 密码(常见为商户号)" -ForegroundColor Red
exit 1
}
}
$serial = $cert.SerialNumber
if ([string]::IsNullOrEmpty($serial)) {
Write-Host "错误: 无法提取证书序列号" -ForegroundColor Red
exit 1
}
$subject = $cert.Subject
if ($subject -match 'CN=([^,]+)') { $mchid = $Matches[1].Trim() }
else { $mchid = "(无法从证书 CN 字段提取,请手动确认)" }
$signContent = $SignString -replace '\\n', "`n"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($signContent)
$signatureBytes = $null
# Windows PowerShell 5.1 / .NET Framework:使用 PrivateKey + SignHash
$legacyRsa = $cert.PrivateKey
if ($null -ne $legacyRsa) {
try {
$sha256 = New-Object System.Security.Cryptography.SHA256CryptoServiceProvider
$hash = $sha256.ComputeHash($bytes)
$oid = [System.Security.Cryptography.CryptoConfig]::MapNameToOID("SHA256")
$signatureBytes = $legacyRsa.SignHash($hash, $oid)
} catch {
$signatureBytes = $null
} finally {
if ($null -ne $sha256) { $sha256.Dispose() }
}
}
# PowerShell 7+ / .NET 4.6+:使用 RSACertificateExtensions::GetRSAPrivateKey
if ($null -eq $signatureBytes) {
try {
$rsaType = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]
$rsa = $rsaType::GetRSAPrivateKey($cert)
if ($null -ne $rsa) {
$signatureBytes = $rsa.SignData($bytes,
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
}
} catch {
$signatureBytes = $null
}
}
if ($null -eq $signatureBytes) {
Write-Host "错误: 无法提取私钥或签名失败,请确认 P12 文件包含私钥" -ForegroundColor Red
exit 1
}
try {
$signature = [Convert]::ToBase64String($signatureBytes)
} catch {
Write-Host "错误: 签名失败" -ForegroundColor Red
exit 1
}
Write-Host "---------- 签名结果开始 ----------" -ForegroundColor Green
Write-Host "API 证书序列号(serial_no): $serial"
Write-Host "时间戳(timestamp): $Timestamp"
Write-Host "随机串(nonce_str): $NonceStr"
Write-Host "API 证书中的商户号: $mchid"
Write-Host "签名值(signature): $signature"
Write-Host "---------- 签名结果结束 ----------" -ForegroundColor Green
"""
wechatpay-docs-sync.py — 微信支付知识库远程同步工具
用法:
python3 wechatpay-docs-sync.py update # 检查远程并同步(含首次安装)
python3 wechatpay-docs-sync.py --encoding utf-8 update # 显式锁定 UTF-8(推荐 Windows)
"""
from __future__ import annotations
import json
import locale
import os
import shutil
import stat
import sys
import tarfile
import tempfile
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
VERSION = "1.0"
# ==== 脚本配置 ====
SCRIPT_DIR = Path(__file__).resolve().parent # scripts directory/
SKILL_DIR = SCRIPT_DIR.parent # skill directory/
DOCS_URL = "https://wx.gtimg.com/resource/wechatpay_api/wechatpay-docs.zip"
DOCS_TARGET_DIR = SKILL_DIR / "assets"
STATE_FILE = SCRIPT_DIR / ".wechatpay-docs-sync-state.json"
CHECK_INTERVAL_HOURS = 12
_TZ = timezone(timedelta(hours=8))
# ==== HTTP header(统一小写) ====
# 代理/网关可能会改写 Header 字段名大小写;字段名本身大小写不敏感。
# 这里统一将“字段名”转为大写进行匹配,避免因大小写变化导致取值失败。
H_LAST_MODIFIED = "LAST-MODIFIED"
H_ETAG = "ETAG"
H_CONTENT_LENGTH = "CONTENT-LENGTH"
# ==== 状态文件 key ====
S_LAST_CHECK = "LAST_CHECK_TIME"
S_LAST_UPDATE = "LAST_UPDATE_TIME"
S_REMOTE_MODIFIED = "REMOTE_LAST_MODIFIED"
S_REMOTE_ETAG = "REMOTE_ETAG"
USER_AGENT = f"{Path(__file__).stem}/{VERSION}"
IGNORED_FILES = {".DS_Store", "Thumbs.db", "__MACOSX"}
DOCS_FILE_GLOB = "*.md"
ZIP_FALLBACK_ENCODING = "cp437"
DEFAULT_IO_ENCODING = "utf-8"
# ==== 编码 ====
def _set_windows_console_utf8() -> None:
if sys.platform != "win32":
return
try:
import ctypes
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
kernel32.SetConsoleOutputCP(65001)
kernel32.SetConsoleCP(65001)
except (OSError, AttributeError):
pass
def _bootstrap_encoding(encoding: str = DEFAULT_IO_ENCODING) -> None:
"""锁定脚本 I/O 编码,降低系统区域/终端代码页对中文路径与输出的干扰。"""
normalized = (encoding or DEFAULT_IO_ENCODING).strip().lower()
os.environ["PYTHONIOENCODING"] = f"{normalized}:replace"
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
try:
stream.reconfigure(encoding=normalized, errors="replace")
except (OSError, ValueError, AttributeError):
pass
for name in ("en_US.UTF-8", "C.UTF-8", "UTF-8"):
try:
locale.setlocale(locale.LC_ALL, name)
break
except locale.Error:
continue
if sys.platform == "win32":
_set_windows_console_utf8()
def _parse_cli_args(argv: list[str]) -> tuple[str, str]:
"""解析可选 --encoding / -E,返回 (command, encoding)。"""
encoding = DEFAULT_IO_ENCODING
args = list(argv)
i = 0
while i < len(args):
token = args[i]
if token in ("--encoding", "-E") and i + 1 < len(args):
encoding = args[i + 1]
del args[i : i + 2]
continue
if token.startswith("--encoding="):
encoding = token.split("=", 1)[1]
del args[i]
continue
i += 1
return (args[0] if args else "", encoding)
# ==== 状态管理 ====
def _now_iso() -> str:
"""返回当前时间的 ISO 8601 字符串(东八区)。"""
return datetime.now(_TZ).isoformat(timespec="seconds")
def _load_state() -> dict:
"""从 STATE_FILE 读取同步状态,文件不存在则返回空 dict。"""
if STATE_FILE.exists():
state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
# 兼容:部分环境可能会改写 header value 的大小写;本地 state 统一按小写存取。
if isinstance(state.get(S_REMOTE_ETAG), str):
state[S_REMOTE_ETAG] = state[S_REMOTE_ETAG].lower()
if isinstance(state.get(S_REMOTE_MODIFIED), str):
state[S_REMOTE_MODIFIED] = state[S_REMOTE_MODIFIED].lower()
return state
return {}
def _save_state(state: dict) -> None:
"""将同步状态写入 STATE_FILE。"""
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(
json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
# ==== 远程资源 ====
def _head_remote() -> dict:
"""HEAD 请求,返回 last_modified / etag / content_length 或 error。"""
req = Request(DOCS_URL, method="HEAD")
req.add_header("User-Agent", USER_AGENT)
try:
with urlopen(req, timeout=15) as resp:
headers = {k.upper(): v for k, v in resp.headers.items()}
# value 统一小写用于比较/落盘,避免被代理改写大小写导致误判
lm = headers.get(H_LAST_MODIFIED)
etag = headers.get(H_ETAG)
return {
H_LAST_MODIFIED: lm.lower() if isinstance(lm, str) else lm,
H_ETAG: etag.lower() if isinstance(etag, str) else etag,
H_CONTENT_LENGTH: headers.get(H_CONTENT_LENGTH),
}
except (URLError, HTTPError) as exc:
return {"error": str(exc)}
def _within_interval(state: dict) -> bool:
"""判断距上次检查是否不足 CHECK_INTERVAL_HOURS 小时。"""
ts = state.get(S_LAST_CHECK)
if not ts:
return False
elapsed = datetime.now(_TZ) - datetime.fromisoformat(ts)
return elapsed.total_seconds() < CHECK_INTERVAL_HOURS * 3600
def _remote_changed(state: dict, remote: dict) -> bool:
"""比较远程 ETag / Last-Modified 与本地记录,判断是否有变化。"""
r_etag = remote.get(H_ETAG)
if r_etag and state.get(S_REMOTE_ETAG):
return str(r_etag).lower() != str(state[S_REMOTE_ETAG]).lower()
r_lm = remote.get(H_LAST_MODIFIED)
if r_lm and state.get(S_REMOTE_MODIFIED):
return str(r_lm).lower() != str(state[S_REMOTE_MODIFIED]).lower()
return True # 无法判断时视为有变化
# ==== 下载与解压 ====
def _download(dest: Path) -> None:
"""从 DOCS_URL 下载压缩包到 dest,显示下载进度。"""
req = Request(DOCS_URL)
req.add_header("User-Agent", USER_AGENT)
with urlopen(req, timeout=300) as resp:
headers = {k.upper(): v for k, v in resp.headers.items()}
total = headers.get(H_CONTENT_LENGTH)
total = int(total) if total else None
done = 0
last_pct = -1
last_mb = -1
is_tty = sys.stdout.isatty()
with open(dest, "wb") as fp:
while True:
chunk = resp.read(65536)
if not chunk:
break
fp.write(chunk)
done += len(chunk)
if total:
pct = done * 100 // total
if pct == last_pct:
continue
last_pct = pct
# 非 TTY(IDE 输出区、管道等)下 \r 无法覆写同行,按里程碑换行
if not is_tty and pct % 10 != 0 and pct < 100:
continue
msg = (
f" 下载: {done / 1048576:.1f} MB / "
f"{total / 1048576:.1f} MB ({pct}%)"
)
if is_tty:
print(f"\r{msg}", end="", flush=True)
else:
print(msg, flush=True)
else:
mb = int(done / 1048576)
if is_tty:
print(f"\r 下载: {done / 1048576:.1f} MB", end="", flush=True)
elif mb > last_mb:
last_mb = mb
print(f" 下载: {done / 1048576:.1f} MB", flush=True)
print()
def _cjk_char_count(text: str) -> int:
return sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
def _try_recover_mojibake(name: str) -> str:
"""UTF-8 文件名被误按 Latin-1/CP1252 解码时,尝试 latin-1→utf-8 还原。"""
try:
recovered = name.encode("latin-1").decode("utf-8")
except (UnicodeDecodeError, UnicodeEncodeError):
return name
if _cjk_char_count(recovered) > _cjk_char_count(name):
return recovered
return name
def _decode_zip_filename(info: zipfile.ZipInfo) -> str:
"""跨平台还原 zip 内文件名(Windows 上 zf.extract 偶发中文乱码,须先修正)。"""
name = info.filename.replace("\\", "/")
if info.flag_bits & 0x800:
return _try_recover_mojibake(name)
for encoding in ("utf-8", "gbk", "gb18030"):
try:
decoded = name.encode(ZIP_FALLBACK_ENCODING).decode(encoding)
if _cjk_char_count(decoded) >= _cjk_char_count(name):
return decoded
except (UnicodeDecodeError, UnicodeEncodeError):
continue
return _try_recover_mojibake(name)
def _win_long_path(path: Path) -> str:
"""Windows 长路径前缀,绕过 MAX_PATH(260) 限制。"""
resolved = str(path.resolve())
if resolved.startswith("\\\\?\\"):
return resolved
if resolved.startswith("\\\\"):
return "\\\\?\\UNC\\" + resolved[2:]
return "\\\\?\\" + resolved
def _mkdir_parents(path: Path) -> None:
try:
path.mkdir(parents=True, exist_ok=True)
except OSError:
if sys.platform != "win32":
raise
os.makedirs(_win_long_path(path), exist_ok=True)
def _write_bytes(path: Path, data: bytes) -> None:
_mkdir_parents(path.parent)
try:
path.write_bytes(data)
except OSError:
if sys.platform != "win32":
raise
with open(_win_long_path(path), "wb") as fp:
fp.write(data)
def _extract_zip(archive: Path, dest: Path) -> None:
"""手动解压 zip,避免 Windows 上 ZipFile.extract 写出乱码中文路径。"""
dest.mkdir(parents=True, exist_ok=True)
zip_kwargs: dict = {}
if sys.version_info >= (3, 11):
zip_kwargs["metadata_encoding"] = "utf-8"
with zipfile.ZipFile(archive, **zip_kwargs) as zf:
for info in zf.infolist():
name = _decode_zip_filename(info)
if not name or name.startswith("__MACOSX"):
continue
parts = name.split("/")
if any(p in IGNORED_FILES for p in parts):
continue
target = dest.joinpath(*parts)
if name.endswith("/") or info.is_dir():
_mkdir_parents(target)
continue
with zf.open(info) as src:
_write_bytes(target, src.read())
def _extract(archive: Path, dest: Path) -> None:
"""解压 zip 或 tar.gz 到 dest。"""
dest.mkdir(parents=True, exist_ok=True)
if zipfile.is_zipfile(archive):
_extract_zip(archive, dest)
return
try:
with tarfile.open(archive) as tf:
tf.extractall(dest, filter="data")
return
except (tarfile.TarError, TypeError):
pass
raise RuntimeError(
f"无法识别压缩格式,请确认下载链接是否为 zip 或 tar.gz 文件: {archive.name}"
)
def _find_content_root(extract_dir: Path) -> Path:
"""若解压后只有单一顶层目录,则进入该目录作为实际内容根。"""
items = [p for p in extract_dir.iterdir() if p.name != "__MACOSX"]
if len(items) == 1 and items[0].is_dir():
return items[0]
return extract_dir
def _chmod_writable(path: Path) -> None:
try:
os.chmod(path, stat.S_IWRITE)
except OSError:
pass
def _on_rm_error(func, path, _exc_info) -> None:
"""Windows 上只读文件/目录删除失败时,先改权限再重试。"""
_chmod_writable(Path(path))
func(path)
def _unlink(path: Path) -> None:
"""删除单个文件或符号链接。"""
_chmod_writable(path)
try:
path.unlink()
return
except OSError:
if sys.platform != "win32":
raise
os.remove(_win_long_path(path))
def _rmdir(path: Path) -> None:
"""删除空目录。"""
_chmod_writable(path)
try:
path.rmdir()
return
except OSError:
if sys.platform != "win32":
raise
os.rmdir(_win_long_path(path))
def _remove_tree(root: Path) -> None:
"""删除目录树;Windows 下自底向上并使用长路径,避免 rmtree 在深层目录失败。"""
if not root.exists():
return
if sys.platform == "win32":
walk_root = _win_long_path(root.resolve())
for dirpath, dirnames, filenames in os.walk(walk_root, topdown=False):
for name in filenames:
fp = os.path.join(dirpath, name)
try:
_chmod_writable(Path(fp))
os.remove(fp)
except OSError:
try:
os.remove(_win_long_path(Path(fp)))
except OSError as exc:
raise OSError(f"无法删除文件: {fp}") from exc
for name in dirnames:
dp = os.path.join(dirpath, name)
try:
_chmod_writable(Path(dp))
os.rmdir(dp)
except OSError:
try:
os.rmdir(_win_long_path(Path(dp)))
except OSError as exc:
raise OSError(f"无法删除目录: {dp}") from exc
try:
_rmdir(root)
except OSError as exc:
raise OSError(f"无法删除目录: {root}") from exc
return
shutil.rmtree(root, onerror=_on_rm_error)
def _copy_file(src: Path, dst: Path) -> None:
"""复制单个文件;Windows 下自动尝试长路径。"""
dst.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(src, dst)
return
except OSError:
if sys.platform != "win32":
raise
shutil.copy2(_win_long_path(src), _win_long_path(dst))
def _copy_tree(src: Path, dst: Path) -> list[tuple[Path, str]]:
"""逐文件复制目录树,返回 (相对路径, 错误信息) 列表。"""
dst.mkdir(parents=True, exist_ok=True)
errors: list[tuple[Path, str]] = []
for item in sorted(src.rglob("*")):
if item.name in IGNORED_FILES or item.name == "__MACOSX":
continue
rel = item.relative_to(src)
target = dst / rel
if item.is_dir():
try:
target.mkdir(parents=True, exist_ok=True)
except OSError as exc:
if sys.platform == "win32":
try:
os.mkdir(_win_long_path(target), exist_ok=True)
except OSError as exc2:
errors.append((rel, str(exc2)))
else:
errors.append((rel, str(exc)))
continue
try:
_copy_file(item, target)
except OSError as exc:
errors.append((rel, str(exc)))
return errors
def _clear_docs_dir() -> None:
"""删除 DOCS_TARGET_DIR 下的所有文件与子目录(保留 assets 目录本身)。"""
if not DOCS_TARGET_DIR.exists():
return
resolved = DOCS_TARGET_DIR.resolve()
skill_root = SKILL_DIR.resolve()
if not str(resolved).startswith(str(skill_root)):
raise RuntimeError(f"目标目录不在 skill 范围内,拒绝清空: {DOCS_TARGET_DIR}")
for child in DOCS_TARGET_DIR.iterdir():
if child.is_symlink():
_unlink(child)
elif child.is_dir():
_remove_tree(child)
else:
_unlink(child)
# ==== 命令 ====
def _is_installed() -> bool:
"""判断本地知识库目录是否存在且非空。"""
return DOCS_TARGET_DIR.exists() and any(DOCS_TARGET_DIR.iterdir())
def cmd_update() -> None:
"""检查远程版本;有更新或首次安装时下载、清空 assets 并写入新版本。"""
state = _load_state()
installed = _is_installed()
if installed and _within_interval(state):
last = state.get(S_LAST_CHECK, "未知")
print(
f"知识库已是最新(上次检查: {last},{CHECK_INTERVAL_HOURS}H 内无需重复检查)。"
)
return
print("正在检查远程文档版本…")
remote = _head_remote()
if "error" in remote:
print(
f"无法连接远程服务器,请检查网络后重试。\n 错误详情: {remote['error']}",
file=sys.stderr,
)
sys.exit(1)
state[S_LAST_CHECK] = _now_iso()
if installed and not _remote_changed(state, remote):
_save_state(state)
print("远程文档未发生变化,当前已是最新,无需更新。")
return
if not installed:
print("本地尚未安装知识库,开始首次下载…")
else:
print(
f"检测到远程文档已更新({remote.get(H_LAST_MODIFIED, '时间未知')}),开始下载…"
)
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
suffix = Path(DOCS_URL.split("?")[0]).suffix or ".zip"
archive = tmp / f"docs{suffix}"
_download(archive)
print("下载完成,正在解压…")
extract_dir = tmp / "out"
_extract(archive, extract_dir)
new_root = _find_content_root(extract_dir)
print("正在写入知识库...")
DOCS_TARGET_DIR.parent.mkdir(parents=True, exist_ok=True)
_clear_docs_dir()
copy_errors = _copy_tree(new_root, DOCS_TARGET_DIR)
if copy_errors:
print(
f"写入知识库时出现 {len(copy_errors)} 个文件错误(常见于 Windows 长路径或权限问题):",
file=sys.stderr,
)
for rel, msg in copy_errors[:10]:
print(f" - {rel}: {msg}", file=sys.stderr)
if len(copy_errors) > 10:
print(f" … 另有 {len(copy_errors) - 10} 个错误未列出", file=sys.stderr)
sys.exit(1)
state[S_LAST_UPDATE] = _now_iso()
# 为了抵抗代理/网关对 header value 的大小写改写,这里落盘时统一做小写。
state[S_REMOTE_MODIFIED] = str(remote.get(H_LAST_MODIFIED, "") or "").lower()
state[S_REMOTE_ETAG] = str(remote.get(H_ETAG, "") or "").lower()
_save_state(state)
count = sum(1 for _ in DOCS_TARGET_DIR.rglob(DOCS_FILE_GLOB))
print(f"更新完成,当前共 {count} 篇文档。")
# ==== 入口 ====
_USAGE = """\
用法: python3 wechatpay-docs-sync.py [--encoding utf-8] update
update 检查远程是否有更新;有变化时下载并全量替换本地知识库(含首次安装)
默认 12 小时内不重复检查远程
--encoding, -E ENC 锁定脚本 stdout/stderr 编码(默认 utf-8;Windows 推荐显式指定)
示例(Windows):
python wechatpay-docs-sync.py --encoding utf-8 update
set PYTHONUTF8=1 && python wechatpay-docs-sync.py update"""
def main(argv: list[str] | None = None) -> None:
raw = list(argv if argv is not None else sys.argv[1:])
if not raw or raw[0] in ("-h", "--help"):
print(_USAGE)
sys.exit(0)
cmd, encoding = _parse_cli_args(raw)
_bootstrap_encoding(encoding)
if cmd != "update":
print(f"未识别到有效命令: {cmd or '(空)'}\n")
print(_USAGE)
sys.exit(1)
cmd_update()
if __name__ == "__main__":
main()