chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
name: Collect Traffic Data
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
collect:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Collect clone data
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.TRAFFIC_TOKEN }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
gh api "repos/$REPO/traffic/clones" > /tmp/clones.json 2>/dev/null || echo '{"count":0,"uniques":0,"clones":[]}' > /tmp/clones.json
|
||||
|
||||
- name: Collect star count
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.TRAFFIC_TOKEN }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
gh api "repos/$REPO" --jq '.stargazers_count' > /tmp/stars.txt
|
||||
|
||||
- name: Clone private repo
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.TRAFFIC_TOKEN }}
|
||||
run: |
|
||||
git clone https://x-access-token:${GH_TOKEN}@github.com/zhangpeng319/wechatpay-skills-traffic.git /tmp/private-repo
|
||||
|
||||
- name: Update traffic data
|
||||
run: |
|
||||
python3 << 'SCRIPT'
|
||||
import json, os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
with open("/tmp/clones.json", "r") as f:
|
||||
clones_data = json.load(f)
|
||||
|
||||
with open("/tmp/stars.txt", "r") as f:
|
||||
current_stars = int(f.read().strip())
|
||||
|
||||
file_path = "/tmp/private-repo/traffic-data.json"
|
||||
if os.path.exists(file_path):
|
||||
with open(file_path, "r") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {"daily_clones": {}, "daily_stars": {}}
|
||||
|
||||
data.setdefault("daily_clones", {})
|
||||
data.setdefault("daily_stars", {})
|
||||
|
||||
for item in clones_data.get("clones", []):
|
||||
date_key = item["timestamp"][:10]
|
||||
data["daily_clones"][date_key] = item["count"]
|
||||
|
||||
data["daily_clones"] = dict(sorted(data["daily_clones"].items()))
|
||||
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
previous_stars = data.get("stars_total", current_stars)
|
||||
star_increment = max(0, current_stars - previous_stars)
|
||||
data["daily_stars"][today] = star_increment
|
||||
data["stars_total"] = current_stars
|
||||
data["daily_stars"] = dict(sorted(data["daily_stars"].items()))
|
||||
|
||||
with open(file_path, "w") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
total_clones = sum(data["daily_clones"].values())
|
||||
total_star_increments = sum(data["daily_stars"].values())
|
||||
print(
|
||||
f"Done. Clone days: {len(data['daily_clones'])}, total clones: {total_clones}, "
|
||||
f"star days: {len(data['daily_stars'])}, stars +{star_increment} today "
|
||||
f"(total {current_stars}, cumulative increments: {total_star_increments})"
|
||||
)
|
||||
SCRIPT
|
||||
|
||||
- name: Commit and push to private repo
|
||||
run: |
|
||||
cd /tmp/private-repo
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add traffic-data.json
|
||||
git diff --cached --quiet || git commit -m "chore: update traffic data $(date -u +%Y-%m-%d)"
|
||||
git push
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2024–2025 WeChat Pay
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,57 @@
|
||||
## 微信支付 Skills
|
||||
|
||||
微信支付为 AI Agent 提供了专业的技能包(Skills),将产品的业务知识、代码示例和接入规范以 Agent 可理解的方式组织,使 Agent 能够准确地协助开发者完成微信支付的接入工作。
|
||||
|
||||
`wechatpay-payment-integration` 是微信支付接入的统一入口 Skill,适用于所有微信支付产品的接入场景,提供以下四大能力:
|
||||
|
||||
- **产品选型** — 根据用户业务场景匹配并推荐合适的微信支付产品,并说明选型理由
|
||||
- **示例代码** — 根据用户索要的接口和开发语言,给出官方示例代码和接口文档 URL,复制即可运行
|
||||
- **接入质量评估** — 以金融支付专家视角扫描用户接入代码,覆盖安全合规、资金链路及业务常见质量问题,按 🔴🟡🟠 分级输出问题清单和修复方向
|
||||
- **答疑与排障** — 解答接入中遇到的各类问题,根据错误码或问题现象定位原因并给出解决方案
|
||||
|
||||
下面具体说明每块能力。
|
||||
|
||||
### 1. 产品选型
|
||||
|
||||
**能解决什么问题**:当开发者明确要接入某个微信支付产品后,帮你在该产品内部的多种方案中根据具体业务场景匹配合适的方案,并给出选型理由。
|
||||
|
||||
**举个例子**:
|
||||
- "我做的是公众号点餐,应该用 JSAPI 还是 H5 支付?"
|
||||
- "我们是 PC 端网站收银,该选 Native 还是 H5?"
|
||||
- "线下门店扫码付款,用付款码支付还是 Native 支付?"
|
||||
|
||||
### 2. 示例代码
|
||||
|
||||
**能解决什么问题**:当开发者要把一个完整业务流程跑通(比如下单 → 支付 → 回调 → 退款),按业务流程维度给出端到端可运行的官方代码示例(curl / Java / Go),把签名生成、证书加载、回调验签、敏感字段加解密都包含在内,参数使用真实字段名,复制即可运行。
|
||||
|
||||
**举个例子**:
|
||||
- "给我一份 JSAPI 下单 + 前端调起 + 回调验签的完整代码(Java / Go 都行)"
|
||||
- "商品券创建批次 → 发券 → 核销 → 退券,串起来给一份示例"
|
||||
- "退款回调里的 `req_info` 怎么解密,给段代码"
|
||||
|
||||
### 3. 接入质量评估
|
||||
|
||||
**能解决什么问题**:当开发者准备上线,担心代码里有隐患时,以金融支付专家视角扫描项目里的支付相关代码,逐项检查签名算法、回调验签、敏感字段加密、幂等控制、必接接口(如对账、退款查询)等关键项,按 🔴🟡🟠 分级输出问题清单(哪里有问题、为什么是问题、参考的官方规范、修复方向),帮助开发者在上线前发现并修复隐患。
|
||||
|
||||
**举个例子**:
|
||||
- "上线前帮我扫一下整个支付模块,看有没有隐患"
|
||||
- "我这段回调处理代码有没有问题?"
|
||||
- "检查下我的签名生成代码是不是规范"
|
||||
- "我接的JSAPI支付有没有漏掉必接接口?"
|
||||
|
||||
### 4. 答疑与排障
|
||||
|
||||
**能解决什么问题**:解答开发者在接入过程中遇到的各类问题——知识查询、流程说明、接口规则咨询、字段含义、错误码释义等;当开发者调用接口报错或行为异常时,根据提供的错误码、错误信息、请求/响应报文定位原因并给出解决方案。
|
||||
|
||||
**举个例子**:
|
||||
- "支付回调如果一直返回失败,微信会重试几次?间隔多久?"
|
||||
- "微信支付平台证书和 API 证书有什么区别?"
|
||||
|
||||
## 交流与反馈
|
||||
|
||||
欢迎填写问卷,帮助我们持续改进:🔗 [问卷链接](https://wj.qq.com/s2/26981880/3d9d/)
|
||||
|
||||
在使用过程中遇到问题、有改进建议,或者想和其他开发者交流接入经验,欢迎扫码加入 **微信支付 Skills 交流群**,与官方团队和社区开发者一起讨论:
|
||||
|
||||
|
||||
<img width="375" height="375" alt="wecom-temp-163912-2c4af5449820ab21d77a688cae86249f" src="https://github.com/user-attachments/assets/4503e74c-7e4d-4064-86c5-d8e4b6928827" />
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`wechatpay-apiv3/wechatpay-skills`
|
||||
- 原始仓库:https://github.com/wechatpay-apiv3/wechatpay-skills
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: wechatpay-payment-integration
|
||||
description: '微信支付(WeChat Pay)相关问题的统一入口,处理与微信支付接入、产品、开发、运营、品牌经营相关的咨询,提供产品选型、官方示例代码、接入质量评估、答疑与排障。Use when user mentions "微信支付", "微信收款", "WeChat Pay", "JSAPI", "APP支付", "H5支付", "Native支付", "扫码支付", "付款码", "小程序支付", "合单支付", "医保支付", "微信支付分", "分账", "转账", "委托代扣", "周期扣款", "代金券", "商家券", "特约商户进件", "服务商", "sub_mchid", "APIv2", "APIv3", "回调", "签名", "证书", "错误码", "OpenID", "品牌经营", "品牌经营平台", "品牌门店", "商家名片", "名片会员", "商品券", "摇一摇有优惠", "摇优惠", "品牌入驻", or asks to "推荐支付方式/产品选型", "要接口或示例代码", "做接入代码质量审查/上线前检查", "解释字段含义或接口规则", "排查报错/查单/支付问题".'
|
||||
author: wechatpay
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# 微信支付全产品接入指引
|
||||
|
||||
## 全局交互规范
|
||||
|
||||
> ‼️ 本规范所有能力、所有对话轮次通用,优先级高于各能力局部规则。
|
||||
|
||||
1. **所有问题必须得到用户明确回答后才能继续。** 一次提多个问题时逐一检查每个回答;未答的再次追问,**严禁自行假设、推断或使用默认值**。
|
||||
2. **境内/境外路由**:本 Skill 默认只支持境内商户;用户提及境外/跨境/海外时,提示其安装 `wechatpay-global-payment` Skill。
|
||||
3. **分步确认协议**:
|
||||
- **① 明确需求**:先给出初步判断或原因分析,不堆参数清单。
|
||||
- **② 征得同意**:主动说下一步能做什么,**等用户明确同意**再继续;严禁未表态就收参数或执行。
|
||||
- **③ 收集信息**:同意后告知需要哪些信息并逐项收集,收齐才执行。
|
||||
- **④ 执行前确认**:执行前简述即将做什么,确认同意再执行;线上环境额外提示风险。
|
||||
4. **按步骤输出**:涉及多个环节的操作指引或排查流程时,每次只输出当前步骤的内容,完成后等用户反馈再继续下一步;简单知识问答可一次性回答。
|
||||
5. **【强制】知识准确性约束**:
|
||||
- **禁止凭记忆编造,必须强制检索**:所有接口、字段、错误码、代码示例必须来自知识库中的官方文档;知识类问题必须先搜索知识库 → 读取文档 → 基于文档回答,**严禁跳过检索直接生成答案**。
|
||||
- **引用必须可溯源,未覆盖如实告知**:回答中涉及的任何技术细节必须能追溯到知识库文档路径 + 官方文档 URL;若经充分检索仍未找到相关文档,应明确告知用户该问题超出知识库覆盖范围,**不要硬凑答案**。
|
||||
6. **文档加载失败时**:若任何文档链接无法读取(文件不存在),先执行知识库同步脚本 `python3 <SKILL目录>/scripts/wechatpay-docs-sync.py update` 再重试。
|
||||
|
||||
## 前置步骤(Skill 加载后立即执行)
|
||||
|
||||
> ⚠️ **优先级高于一切其他操作(包括读取文档、提问、回复用户)。不得跳过或延后。**
|
||||
|
||||
1. **知识库同步**:运行 `python3 <SKILL目录>/scripts/wechatpay-docs-sync.py update`,等待完成。每 12 小时执行一次即可,若不确定上次执行时间,直接运行。
|
||||
2. **加载通用文档**:
|
||||
- [📄 基础概念及业务介绍](./references/基础概念及业务介绍.md)
|
||||
- [📄 知识库使用指南](./assets/wechatpay-docs-guide.md)
|
||||
|
||||
---
|
||||
|
||||
## 能力概览
|
||||
|
||||
1. **产品选型** — 根据用户业务场景匹配并推荐合适的微信支付产品
|
||||
2. **示例代码** — 根据用户索要的接口和开发语言,给出官方示例代码和接口文档 URL
|
||||
3. **接入质量评估** — 以金融支付专家视角扫描用户接入代码,覆盖安全合规、资金链路及开发时业务常见质量问题,按 🔴🟡🟠 分级输出问题清单和修复方向
|
||||
4. **答疑与排障** — 解答接入中遇到的各类问题,根据错误码或问题现象定位原因并给出解决方案
|
||||
|
||||
---
|
||||
|
||||
## 能力1:产品选型
|
||||
|
||||
> 当用户不确定该用哪种微信支付产品、或想了解各产品区别和适用场景时使用此能力。
|
||||
|
||||
```text
|
||||
用户问题
|
||||
|
|
||||
└──> 加载 <SKILL目录>/assets/wechatpay-product-overview.md
|
||||
(含支付产品 + 品牌经营产品)
|
||||
|
|
||||
└── 若聚焦「选哪种券」──> 加载 <SKILL目录>/assets/brand/品牌商户/商品券(单券)/附录/券类型选型.md
|
||||
(商品券 10 种类型的选型决策)
|
||||
```
|
||||
|
||||
1. 先读对应总览,根据用户业务场景匹配推荐产品并将产品概述发给用户确认;信息不足时,先追问业务场景细节及角色再选型。
|
||||
2. 用户想了解更多细节时,按使用指南定位到该产品的「产品介绍」+「开发接入准备」文档,读取后回答。
|
||||
|
||||
---
|
||||
|
||||
## 能力2:示例代码
|
||||
|
||||
> 当用户需要某个微信支付接口的示例代码或接口文档时使用此能力。
|
||||
|
||||
1. **严格基于官方文档**:所有示例代码必须来源于知识库中的官方文档,不得凭模型记忆生成接口、字段或代码片段。信息不全时,先向用户追问。同一接口存在多套文档时,先向用户确认角色再返回对应版本。
|
||||
2. **官方语言(curl / Java / Go)**:按[知识库使用指南](./assets/wechatpay-docs-guide.md)定位到该产品 `API列表/` 下的接口文档,读取对应语言的请求示例文件输出;前端调起 / 回调类接口无后端请求示例时,直接给出该接口文档内容。
|
||||
3. **其他语言(非 curl / Java / Go)**:**禁止直接生成代码**,先主动征得用户同意(文案必须明示「参考实现 / 非官方维护」):
|
||||
|
||||
- 同意 → 以官方 Java 为基准翻译生成,每段代码下方必须附免责块 ⚠️
|
||||
「AI 参考官方 Java 翻译生成,非官方维护。」
|
||||
「请开发人员自行审查 AI 生成的代码逻辑,上线前充分测试以确保其适用性与准确性,AI 不对生成代码的正确性承担责任。」
|
||||
- 未同意 → 只发官方 curl / Java / Go 文档链接(curl 不依赖特定编程语言,适合作为兜底参考)。
|
||||
|
||||
---
|
||||
|
||||
## 能力3:接入质量评估
|
||||
|
||||
> 当用户希望对已有的接入代码做质量审查或上线前检查时使用此能力。
|
||||
|
||||
加载:[接入质量检查清单](./references/接入质量检查清单.md)
|
||||
|
||||
1. 加载接入质量检查清单(质检人设 + 三大铁律 + 通用问题雷达)。
|
||||
2. 若用户已明确产品,按使用指南定位到该产品的「开发指引」文档,提取其中「注意事项」作为业务专属问题雷达;产品不明确则仅用通用规则扫描。
|
||||
3. 合并「通用清单 + 业务专属注意事项(如有)」→ 扫描 → 追链路 → 做预演 → 按 🔴🟡🟠 分级输出问题清单,致命问题置顶,每个问题给修复方向。
|
||||
|
||||
---
|
||||
|
||||
## 能力4:答疑与排障
|
||||
|
||||
> ‼️ **路由规则:凡是不属于能力 1(产品选型)、能力 2(示例代码)、能力 3(接入质量评估)的用户问题,一律进入本能力处理。** 包括但不限于:知识查询、流程说明、接口规则咨询、字段含义、错误码含义、报错排查等。
|
||||
>
|
||||
> **本能力是默认兜底能力**——当无法明确匹配到能力 1/2/3 时,必须进入本能力的子模块流程。
|
||||
|
||||
### 问题识别与分流
|
||||
|
||||
根据用户输入判断问题类型,分流到对应子模块:
|
||||
|
||||
```text
|
||||
用户问题
|
||||
|
|
||||
+-- 需要查单排障(贴了接口报错/异常响应想定位原因,或提供了订单号想确认交易状态)
|
||||
| |
|
||||
| └──> APIv3 接口动态排障
|
||||
|
|
||||
+-- 其他所有问题(知识类问题、流程咨询、接口说明、字段含义、错误码释义、产品规则、回调格式等)
|
||||
|
|
||||
└──> 文档检索与问答 【默认分支】
|
||||
```
|
||||
|
||||
### 子模块清单
|
||||
|
||||
| 子模块 | 功能 |
|
||||
| --- | --- |
|
||||
| [文档检索与问答](./references/文档检索与问答.md) | **默认子模块**。检索本地同步的微信支付官方文档知识库,根据用户问题查找相关文档并作答。 |
|
||||
| [APIv3接口动态排障](./references/APIv3接口动态排障.md) | 查询支付单、退款单,协助排查查单失败 |
|
||||
|
||||
### 调用原则
|
||||
|
||||
1. **先加载子模块文档再行动**:确定分流方向后,必须先 `Read` 对应子模块的 reference 文档(如 `./references/文档检索与问答.md`)获取完整工作流,严格按其中定义的步骤顺序执行。**禁止跳过加载子模块文档直接自行搜索/读取知识库文件。** 检索到同一产品存在多套文档时,先向用户确认角色再返回对应版本。
|
||||
2. 根据当前步骤按需读取 `references/` 下的其他补充文档,不要一次性全量加载
|
||||
3. 文档检索与问答作答后,若判断仍需实际查单才能确认(如用户提到具体订单、或文档方案需验证交易状态),主动询问用户是否需要帮忙查单,同意后进入 APIv3 接口动态排障流程
|
||||
|
||||
---
|
||||
|
||||
> 以下信息与技能能力无关,仅供查阅。
|
||||
|
||||
## 📋 用户调研
|
||||
|
||||
如果您有任何建议或反馈,欢迎填写:[微信支付 Skill 用户调研问卷](https://wj.qq.com/s2/26981880/3d9d/)
|
||||
@@ -0,0 +1,233 @@
|
||||
# APIv3 接口动态排障
|
||||
|
||||
## 前置依赖
|
||||
|
||||
进入本流程前,须确认 **`wechatpay-dev-cli` 已安装且可用**(安装详见 [wechatpay-dev-cli使用说明](./wechatpay-dev-cli使用说明.md))。
|
||||
|
||||
1. **校验 CLI**:执行 `wechatpay-dev-cli --version`,能正常输出版本号(如 `1.0.0`)再继续。
|
||||
2. **未安装时**:按使用说明安装(公网 `npm install -g @tenpay/wechatpay-dev-cli`),安装后再次执行 `--version` 确认。
|
||||
|
||||
校验通过后再执行后续的流程。
|
||||
|
||||
## 流程
|
||||
|
||||
```mermaid
|
||||
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**:
|
||||
|
||||
```bash
|
||||
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`):
|
||||
|
||||
```powershell
|
||||
[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:
|
||||
|
||||
```bash
|
||||
wechatpay-dev-cli api list --mode <merchant|partner>
|
||||
```
|
||||
|
||||
将 CLI 输出的接口列表展示给开发者,由其选定一个 **接口ID**(如 `payment.QueryByOutTradeNo`)。
|
||||
|
||||
### 1.3 查询接口所需参数并收集
|
||||
|
||||
针对选定的接口ID,查看该接口在对应 `mode` 下需要填写的 Path / Query 参数:
|
||||
|
||||
```bash
|
||||
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 向开发者补要对应字段后重试:
|
||||
|
||||
```bash
|
||||
wechatpay-dev-cli api build <接口ID> \
|
||||
--mode <merchant|partner> \
|
||||
--params '<JSON>'
|
||||
```
|
||||
|
||||
CLI 输出示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"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 开场话术
|
||||
|
||||
```text
|
||||
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
|
||||
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
|
||||
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` 分析结果。
|
||||
|
||||
```bash
|
||||
wechatpay-dev-cli api call <接口ID> \
|
||||
--mode <merchant|partner> \
|
||||
--mchid "<Authorization 中的 mchid>" \
|
||||
--params '<JSON>' \
|
||||
--serial_no "<Step3>" \
|
||||
--timestamp "<Step3>" \
|
||||
--nonce_str "<Step3>" \
|
||||
--signature "<Step3>"
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
# wechatpay-dev-cli 使用说明
|
||||
|
||||
> 本 Skill在 **「能力 4 → APIv3 接口动态排障」** 分支依赖 `wechatpay-dev-cli`。
|
||||
> 产品选型、示例代码、文档问答、接入质检 **不需要** 安装 CLI。
|
||||
|
||||
## 检测 CLI 是否可用
|
||||
|
||||
进入 [APIv3接口动态排障](./APIv3接口动态排障.md) 之前,在终端执行:
|
||||
|
||||
```bash
|
||||
wechatpay-dev-cli --version
|
||||
```
|
||||
|
||||
正常时应输出版本号,例如:`1.0.0`
|
||||
能跑通 `--version` 才说明 `Node` 环境、`wechatpay-dev-cli` 环境已经准备好;仅知道 `wechatpay-dev-cli` 这个命令名存在不够。
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
**依赖**:Node.js ≥ 20(包名 `@tenpay/wechatpay-dev-cli`)。
|
||||
|
||||
```bash
|
||||
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` 是否原样签名 |
|
||||
@@ -0,0 +1,104 @@
|
||||
# 基础概念及业务介绍
|
||||
|
||||
## 一、角色(接入模式)
|
||||
|
||||
| | 普通商户 | 服务商 | 平台服务商(收付通) | 品牌商户 | 特约商户 |
|
||||
|---|---|---|---|---|---|
|
||||
| **一句话介绍** | 自己申请商户号,自己对接微信支付 API 收款 | 代特约商户对接微信支付;也可受品牌方委托代调品牌经营平台接口 | 先成为服务商,且底下不能有子商户,然后申请收付通权限;适用于电商/O2O 等平台类业务 | 拥有 `brand_id`,通过品牌经营平台(商家名片 / 摇优惠 / 商品券等)做用户运营 | 由服务商进件创建,获得 `sub_mchid`。特约商户可自行登录商户平台申请证书和密钥,用 `sub_mchid` 调用普通商户接口收款(电商子商户、小微/小商户、间连子商户除外) |
|
||||
| **商户号** | `mchid` | `sp_mchid` | `sp_mchid` | 无独立商户号,标识为 `brand_id` | `sub_mchid` |
|
||||
| **资金关系** | 结算到自己的商户账户 | 资金结算到特约商户,服务商通过分账获取佣金 | 资金先进入平台待分账账户,由平台发起分账到二级商户 | 品牌经营不涉及资金结算,属营销与用户运营 | 结算到自己的账户 |
|
||||
| **角色关系** | 独立运作,无上下级 | 一个服务商可管理多个特约商户;也可受多个品牌方委托 | 一个平台服务商下有多个二级商户 | 可由品牌独立接入,也可委托服务商代接(需在品牌经营平台授权) | 归属于某个服务商,由服务商进件创建 |
|
||||
|
||||
---
|
||||
|
||||
## 二、API 版本
|
||||
|
||||
微信支付有三个文档/接口体系,各自独立鉴权:
|
||||
|
||||
| 文档分支 | API 版本 | 鉴权方式 |
|
||||
|---|---|---|
|
||||
| **APIv2** | V2(存量维护) | API 密钥签名(MD5 / HMAC-SHA256),XML 报文 |
|
||||
| **APIv3** | V3(主力版本) | 签名:商户API证书或私钥(`WECHATPAY2-SHA256-RSA2048`);验签:微信支付公钥(推荐)或平台证书。JSON 报文 |
|
||||
| **brand** | 品牌经营专用 | 签名:品牌API证书或私钥(`WECHATPAY-BRAND-SHA256-RSA2048`);验签:微信支付公钥(推荐)或平台证书。JSON 报文 |
|
||||
|
||||
V2 存量维护,不再新增功能,V3 是主力版本。**默认使用 V3**,仅当产品/接口只有 V2 版本时才用 V2。若用户未指定版本,一律按 V3 提供,**严禁主动推荐或引导用户使用 V2**。
|
||||
|
||||
---
|
||||
|
||||
## 三、产品版本偏好
|
||||
|
||||
除 API 版本(V2/V3)外,部分产品本身也存在新旧版本迭代(如商家转账升级版、移动医保支付 2.0 等)。**当同一产品存在多个版本时,默认按最新版本提供文档**;若用户使用旧版本,提示有新版本可升级。
|
||||
|
||||
---
|
||||
|
||||
## 四、核心 ID 及其关系
|
||||
|
||||
| ID | 含义 | 归属 | 获取方式 | 与其他 ID 的关系 | 绑定操作 | 注意事项 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `mchid` | 普通商户号 | 普通商户 | 在商户平台自行申请 | 与 `appid` **多对多**绑定,绑定后方可发起支付 | 商户平台「账户中心-账户设置-APPID授权管理」发起绑定,appid 所在平台确认后生效 | 绑定后**不可解绑** |
|
||||
| `sp_mchid` | 服务商商户号 | 服务商 / 平台服务商 | 在商户平台申请服务商资质 | 与 `sub_mchid` 一对多;与 `mchid` 一对多(一个服务商可管理多个特约商户和普通商户) | 服务商平台「产品中心-APPID账号管理-我关联的APPID账号」新增关联 | — |
|
||||
| `sub_mchid` | 特约商户号 | 特约商户 | 由服务商通过进件接口创建 | 归属于 `sp_mchid`(一对多) | 服务商调用进件接口时自动创建归属关系,无需手动操作 | — |
|
||||
| `appid` | 应用 ID | 公众号 / 小程序 / 移动应用 / 网站应用 | 在公众平台或开放平台创建应用获得 | 与 `mchid` 多对多;与 `brand_id` **多对多**(一个品牌可关联多个 appid,一个 appid 也可关联多个品牌) | 品牌关联:品牌经营平台「账号管理-下单appid管理」;服务商关联:服务商平台「产品中心-APPID账号管理-我关联的APPID账号」 | appid 需先完成认证才能关联;未关联时调接口报 `AppID非法` |
|
||||
| `brand_id` | 品牌 ID | 品牌商户 | 在品牌经营平台申请/创建 | 与 `appid` 多对多;与 `mchid` 多对多(服务商代接时,一个服务商可服务多个品牌,一个品牌也可授权多个服务商) | 服务商代接:品牌在品牌经营平台授权服务商 | 未授权报 `NO_AUTH` |
|
||||
| `openid` | 用户标识 | 用户 × appid | 用户授权某个 appid 后生成 | 同一用户在不同 appid 下 openid 不同;与 `appid` 一一对应,下单时 openid 必须属于所传的 appid | 通过用户授权获取:H5/JSAPI 走网页授权、小程序走 `wx.login`、App 走开放平台授权 | openid 与 appid 不匹配会报错 |
|
||||
|
||||
---
|
||||
|
||||
## 五、证书与密钥
|
||||
|
||||
| 维度 | 商户 API 证书 | 平台证书 | 微信支付公钥 | APIv3 密钥 | APIv2 密钥 | 品牌 API 证书 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **概述** | 证明商户身份,生成请求签名 | 验证微信支付身份、加密敏感字段 | 平台证书的替代方案,功能一致,长期有效更易维护,推荐使用 | 解密 V3 回调通知数据 | V2 请求签名与验签(对称加密,MD5/HMAC-SHA256) | 证明品牌商户身份,生成 brand 接口请求签名。在品牌经营平台申请,与商户 API 证书是两套独立的证书和私钥,**不能混用** |
|
||||
| **数据形式** | apiclient_cert.pem + apiclient_key.pem + .p12 | wechatpay_xxx.pem(带有效期) | pub_key.pem(无有效期) | 32 位字母数字字符串 | 32 位字母数字字符串 | pem 格式(独立的证书序列号和私钥) |
|
||||
| **适用场景** | V3/V2 普通商户及服务商接口 | V3 验签应答/回调、加密姓名证件号等敏感字段(与微信支付公钥二选一) | V3 验签应答/回调、加密敏感字段(与平台证书二选一) | V3 回调通知解密 | V2 全部接口签名验签 | 仅用于 brand 品牌经营接口(签名类型 `WECHATPAY-BRAND-SHA256-RSA2048`),不能调 V3/V2 普通接口 |
|
||||
|
||||
V2 和 V3 是两套独立体系,同一个商户号,两套体系可并存:
|
||||
- V2:APIv2 密钥 + 商户 API 证书
|
||||
- V3:APIv3 密钥 + 商户 API 证书 + 平台证书/微信支付公钥
|
||||
|
||||
---
|
||||
|
||||
## 六、营销业务全景与选型
|
||||
|
||||
微信支付营销的核心是三大券能力,配合发券/投放工具触达用户:
|
||||
|
||||
| 维度 | 商品券 | 代金券 | 商家券 |
|
||||
|---|---|---|---|
|
||||
| **定位** | 品牌优惠券全链路方案;单券(`stock`,单次核销)与多次优惠(`stock_bundle`,3-15 次阶梯核销)两种券型;可投放平台流量与商家自有流量 | 官方满减/单品换购工具;支付前发放、支付中自动核销(实付=订单金额−券额);分预充值与免充值;支持全员/新人/抽奖等场景;卡包过期提醒+防刷 | 满减/换购/折扣三种券型;券码可微信生成或商户自定义(适配已有 Code 体系);支持线上小程序核销与线下扫码核销。**已存量维护、不再迭代**(2025-12-15 起不受理新接入) |
|
||||
| **发券工具/渠道** | 摇一摇有优惠(平台流量);向用户发放商品券 API 或小程序发券组件(商品券独有,和小程序发券插件是两套API)(自有流量) | 商户自有任意场景 API 发券(小程序/H5/App)、小程序发券插件 | H5发券、小程序发券插件、支付有礼(已升级至摇一摇) |
|
||||
| **区别点** | 微信平台流量、支付完成页互动领券;含单券/多次优惠 | 官方满减/折扣券,支付前发放、**支付中自动核销**(无需主动核销) | 已被商品券替代 |
|
||||
| **接口版本(接入模式)** | APIv3(服务商)/ brand(品牌) | APIv3(普通商户 / 服务商) | APIv3(普通商户 / 服务商) |
|
||||
| **功能状态** | **主力**(新接入推荐) | 在用 | **存量·不再迭代** |
|
||||
|
||||
---
|
||||
|
||||
## 七、重点:商品券
|
||||
|
||||
### 7.1 两种发放场景
|
||||
|
||||
商品券有两种发放场景,共享同一套品牌经营底座(`brand_id` + 商品券券源),区别在于触达入口与所需功能:
|
||||
|
||||
**摇一摇有优惠(平台流量)**:用户支付完成后在微信侧"摇一摇·支付完成页"曝光领券。
|
||||
- 前置:品牌入驻(`brand_id`) + 商家名片或品牌门店(二选一,决定用户入口/按位置投放)+ 投放计划(选商品券 + 价格批次)
|
||||
- 服务商模式额外前提:品牌服务商授权(BM)
|
||||
- 接入流程:品牌入驻拿 `brand_id` → 建商家名片或品牌门店(二选一)→ 创建商品券 → 建投放计划(选券 + 价格批次)→ 摇一摇支付完成页曝光领券
|
||||
|
||||
**商户API发券(自有流量)**:商户在小程序/H5/App 等场景主动发券,核心只需 `品牌入驻(brand_id)` + 创建商品券;无需投放计划,商家名片/品牌门店仅按需(按指定门店投放时才需建门店并关联批次)。
|
||||
- 接入流程:品牌入驻拿 `brand_id` → 创建商品券 → 自有场景调用「向用户发放商品券 API」或「小程序发券组件」发放 → 用户使用后调用「核销商品券」核销
|
||||
|
||||
> 两种场景均可按「品牌直连」或「服务商代运营」接入:
|
||||
> - 品牌直连:品牌用自己的 `brand_id` 操作,仅需关联下单 appid(BA 关系),无需 BM 授权。
|
||||
> - 服务商代运营:服务商用自己的 `mchid` 调用 APIv3 合作伙伴接口、请求携带目标 `brand_id` 代品牌操作;必须先经品牌授权(BM 关系)。
|
||||
|
||||
### 7.2 核心概念
|
||||
|
||||
**商品信息 vs 批次信息**:商品券由两层组成,首次创建会**同时创建**商品信息及第一个批次,后续可在同一商品券下追加批次(多批次属同一商品券,一个批次仅属一个商品券)。
|
||||
|
||||
- **商品信息**(跨批次共享):基础信息(券名称、商品图片、原价、展示信息)+ 优惠模式(优惠范围:全场/部分商品可用;券类型:满减/折扣/兑换;使用模式:单券/多次优惠)。
|
||||
- **批次信息**(每次投放可变):优惠规则(可用时间、力度)+ 发放规则(券码分配、发放规则、可用门店范围)+ 使用展示规则。
|
||||
|
||||
> 修改:商品层改用「修改商品券」,批次层改用「修改商品券批次」/「修改商品券批次组」;修改仅对新发券生效。
|
||||
|
||||
**批次 vs 批次组**:
|
||||
- **批次**(仅单券模式):一个批次发一张券、用一次即失效。有效期按单批次维度、各批次可独立设置。
|
||||
- **批次组**(仅多次优惠模式):多个批次的有序集合,一次发放含多张券的"券组"。按顺序核销 3-15 次,每核销一轮微信侧再发下一轮券并回调领券结果。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 如何理解用户问题
|
||||
|
||||
## 概述
|
||||
|
||||
对用户输入做**润色、纠错、指代消解**,得到更清晰、完整的问题表述,全程保持用户原意,不增删关键事实。
|
||||
|
||||
## 原则
|
||||
|
||||
- **忠实**:不改变用户要问什么。
|
||||
- **清晰**:消除歧义,多轮对话补全省略信息。
|
||||
- **节制**:能推断的不反复追问;不过度改写。
|
||||
|
||||
## 多轮:指代消解
|
||||
|
||||
当前问题依赖前文时:
|
||||
|
||||
- 将「它 / 这个接口 / 上面那种」等还原为具体对象(产品名、接口名、错误现象等)。
|
||||
- 继承前文已确定的商户角色、API 版本、业务场景,避免答非所问。
|
||||
- 消解后仍须遵守「保留的技术原文」规则。
|
||||
|
||||
## 单轮:润色与纠错
|
||||
|
||||
**目标**:把零散、口语、含糊的表述整理成适合检索的规范问句。
|
||||
|
||||
- **明确意图**:补全隐含条件,描述清楚「要什么、在什么场景下」。
|
||||
- **规范用语**:纠正错别字与明显笔误;口语可改为书面语,但不改变技术含义。
|
||||
- **去冗余**:删掉重复寒暄,保留与问题相关的关键实体与动作。
|
||||
|
||||
### 保留的技术原文
|
||||
|
||||
润色只整理表述,不改动用户已给出的技术细节。下列片段须**原样保留**(不得改写、替换或「纠正」字面内容,即使看起来像笔误);仅可在其前后补充说明性文字:
|
||||
|
||||
- 官方文档或接口 URL(含文档 `doc_id`)
|
||||
- 接口路径与 HTTP Method
|
||||
- 错误码、错误报文片段
|
||||
- 字段名、参数名、枚举值
|
||||
- 支付产品名、API 证书等官方术语
|
||||
@@ -0,0 +1,68 @@
|
||||
# 微信支付接入质量检查清单(通用)
|
||||
|
||||
> **适用范围**:所有微信支付业务的通用质检框架——境内基础支付、合单支付、境外微信支付、医保支付、委托代扣、微信支付分、商品券、商家券、刷脸支付等。
|
||||
|
||||
## 角色设定:金融支付系统技术专家
|
||||
|
||||
> ‼️ **本节角色、铁律和问题雷达是质检的全部驱动力,必须内化后再审代码。**
|
||||
|
||||
你是金融支付系统技术专家,全栈工程师出身,亲手写过从前端收银台到后端交易引擎的全链路代码。你主导过千万级用户规模的国民级支付系统架构设计,从零搭建过高并发交易平台。你熟悉主流支付平台的接入规范与安全体系,对 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 可让微信侧快速定位到服务日志,是排障最高效的线索 |
|
||||
@@ -0,0 +1,147 @@
|
||||
# 文档检索与问答
|
||||
|
||||
在 `<SKILL目录>/assets/微信支付官网文档/` 知识库内,先用关键词 **全局** `Grep` **探路**,根据命中路径与用户语义 **再缩小范围** 并 `Read` 精读;`<SKILL目录>/assets/wechatpay-docs-guide.md` 与角色/版本判断用于**辅助校验**,不作为锁定目录的唯一依据。
|
||||
|
||||
## 目录索引的定位
|
||||
|
||||
进入任何检索动作之前,必须先 `Read` 加载 `<SKILL目录>/assets/wechatpay-docs-guide.md`**(必须执行,不可跳过)**。该文件包含:
|
||||
|
||||
- **目录索引**:`微信支付官网文档/` 的完整三级目录树
|
||||
- **业务目录结构**:每个业务目录下文档的统一组织方式(产品介绍、开发指引、API 列表、错误码等)
|
||||
- **角色与文档路径对照**:各商户角色(普通商户、服务商、品牌商户等)与文档路径的映射关系及定位步骤
|
||||
- **单篇文档结构**:文件名格式与 front matter 字段说明
|
||||
- **澄清话术**:用户描述模糊时需要向用户确认的场景(券类型、发券方式、证书、密钥、IP、转账等)及对应话术
|
||||
|
||||
回答所依据的正文须全部来自 `<SKILL目录>/assets/微信支付官网文档/` 内经 `Read` 读取的 Markdown。
|
||||
|
||||
## 前置流程(按顺序执行,不可跳过)
|
||||
|
||||
### 1. 澄清话术检查
|
||||
|
||||
对照 `<SKILL目录>/assets/wechatpay-docs-guide.md` 第五节「用户描述模糊时的澄清话术」判断是否需要澄清:用户原文命中触发词(如「证书」「API证书」「券」「密钥」等),但没有说出该类别下某个具体产品名称 → 只输出对应澄清话术并停止。
|
||||
|
||||
### 2. 商户身份判定
|
||||
|
||||
每次用户提问时,按以下顺序判定商户身份,确定检索路径:
|
||||
|
||||
1. **检查** `AGENTS.md`:先读取项目根目录下的 `AGENTS.md`,若其中已记录商户角色,直接使用该角色对应的路径,跳过后续步骤。
|
||||
2. **从用户描述推断**:根据 `<SKILL目录>/assets/wechatpay-docs-guide.md` 第三节「角色与文档路径对照」表中的角色特征(如 `mchid`→普通商户、`sp_mchid`→服务商、`brand_id`→品牌商户等),从用户问题中识别身份关键词。能判断则直接按对应路径检索。
|
||||
3. **无法判断时,全局 Grep 探路**:在全树下 `Grep`,观察命中文档的目录路径分布。
|
||||
4. **命中跨多角色时,必须停止并询问用户**:本轮**只输出以下内容并停止**,不得继续检索或作答:
|
||||
> 找到了以下角色下的相关文档:[列出命中的角色]。请问您需要全部查看,还是只看某个角色的文档?
|
||||
5. **用户选择后,必须先询问是否记录偏好**:向用户确认是否将角色写入 `AGENTS.md`(项目根目录)。若同意则写入,后续会话自动使用该角色;用户可随时在 `AGENTS.md` 中修改。**确认完成后(无论用户同意或拒绝)才可继续后续检索。**
|
||||
|
||||
> **强制规则**:第 4 步触发时,本轮**禁止**执行 `Read`、作答、或任何后续检索动作——必须等待用户回复后再继续。用户回复后**必须先执行第 5 步**(询问是否记录偏好),第 5 步完成前不得进入核心工作流。
|
||||
|
||||
**API 版本歧义**:同一角色路径下 `APIv2` 和 `APIv3` 均有相关文档时,默认优先 **APIv3**(`APIv2` 仅当 V3 侧无对应文档时再查)。用户已明确指定版本的,以用户为准。
|
||||
|
||||
## 核心工作流(必须按顺序)
|
||||
|
||||
建议顺序:**理解问题并拟定检索词** → **全局** `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`、`普通商户`/`合作伙伴` 分支,或 `brand/品牌商户/`(品牌经营平台,独立体系),还是跨多个分支
|
||||
|
||||
本步目的是用**真实命中**校准检索方向,而不是先猜目录。
|
||||
|
||||
当用户问的是**产品分类概览**(如「营销产品都有哪些」「V3 普通商户有几类产品」),可用 `Glob` 列出对应目录的子目录/文件名,直接从路径获取答案,不必逐篇 `Grep`。
|
||||
|
||||
### 第三步:结合命中与用户语义缩小范围
|
||||
|
||||
综合第二步结果与用户问题含义:
|
||||
|
||||
1. **以命中路径为主**:路径明显相关(目录名、文件名与问题直接对应)时,直接进入第四步精读,**不需要**额外读 `front matter` 确认。若命中跨角色/版本,结合用户表述与 **`<SKILL目录>/references/基础概念及业务介绍.md`,筛掉明显无关分支,勿凭猜测丢弃仍有相关命中的分支。
|
||||
2. **角色路径判断优先**:按 `wechatpay-docs-guide.md`「角色与文档路径对照」锁定对应路径。
|
||||
3. **歧义默认优先级**:若用户问题**无法判断** API 版本,且相关文档在 `APIv2`/`APIv3` **两侧均有**,默认优先 `APIv3` 路径再精读。
|
||||
4. **索引作辅助**:当命中分散或难以取舍时,再 `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/` 或 `brand/品牌商户/` 起算的本地文件相对路径(不写 `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` 形如 `https://pay.weixin.qq.com/doc/brand/4015989179.md`);无 `url` 则写 `(本文件 front matter 无 url,勿编造)`,**禁止**自行拼接链接。
|
||||
- **自检**:「相关文档」不超过 3 条;每条链接均来自对应 `.md` 的 `front matter` `url` 字段(去掉 `.md` 后缀)。
|
||||
|
||||
按以下格式组织回答:
|
||||
|
||||
```markdown
|
||||
## [简短答案,直接回答用户问题]
|
||||
|
||||
### 📋 相关文档
|
||||
- **`{本地文件路径}`**:`{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. **禁止编造**:接口、字段、错误码须来自知识库 `Read` 结果,不凭模型记忆编造。
|
||||
7. **知识库未覆盖时如实告知**:若经充分检索仍未找到相关文档,应明确告知用户该问题超出当前知识库覆盖范围,不要硬凑答案。
|
||||
8. **简洁作答**:答案只含直接回答用户问题所需的信息;用户未问的不展开,不堆砌无关内容(见第五步「简洁作答」)。
|
||||
|
||||
## 示例
|
||||
|
||||
**用户**:「合作伙伴 `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 条**。
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/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 "---------- 签名结果结束 ----------"
|
||||
@@ -0,0 +1,199 @@
|
||||
#
|
||||
# 微信支付 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
|
||||
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
wechatpay-docs-sync.py — 微信支付知识库远程同步工具
|
||||
|
||||
用法:
|
||||
python3 wechatpay-docs-sync.py update # 检查远程并同步(含首次安装)
|
||||
"""
|
||||
|
||||
import json
|
||||
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"
|
||||
|
||||
|
||||
# ==== 状态管理 ====
|
||||
|
||||
|
||||
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 _extract(archive: Path, dest: Path) -> None:
|
||||
"""解压 zip 或 tar.gz 到 dest。若 zip 未标记 UTF-8 标志位,做 cp437→utf-8 编码修正。"""
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
if zipfile.is_zipfile(archive):
|
||||
with zipfile.ZipFile(archive) as zf:
|
||||
for info in zf.infolist():
|
||||
if not (info.flag_bits & 0x800):
|
||||
info.filename = info.filename.encode(ZIP_FALLBACK_ENCODING).decode(
|
||||
"utf-8"
|
||||
)
|
||||
zf.extract(info, 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 _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 _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 update
|
||||
|
||||
update 检查远程是否有更新;有变化时下载并全量替换本地知识库(含首次安装)
|
||||
默认 12 小时内不重复检查远程"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
|
||||
print(_USAGE)
|
||||
sys.exit(0)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
if cmd != "update":
|
||||
print(f"未识别到有效命令: {cmd}\n")
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
cmd_update()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user