shaoguo há 3 semanas atrás
commit
036c6b970f
100 ficheiros alterados com 8540 adições e 0 exclusões
  1. 19 0
      .github/ISSUE_TEMPLATE/bug-and-feature-request.md
  2. 71 0
      .github/workflows/codeql-analysis.yml
  3. 71 0
      .github/workflows/go.yml
  4. 13 0
      .gitignore
  5. 34 0
      .revive.toml
  6. 137 0
      CHANGELOG.md
  7. 128 0
      CODE_OF_CONDUCT.md
  8. 104 0
      FAQ.md
  9. 201 0
      LICENSE
  10. 543 0
      README.md
  11. 20 0
      SECURITY.md
  12. 49 0
      UPGRADING.md
  13. 191 0
      cmd/wechatpay_download_certs/wechatpay_download_certs.go
  14. 11 0
      core/auth/credential.go
  15. 48 0
      core/auth/credentials/wechat_pay_credential.go
  16. 189 0
      core/auth/credentials/wechat_pay_credential_test.go
  17. 19 0
      core/auth/signer.go
  18. 41 0
      core/auth/signers/sha256withrsa_signer.go
  19. 175 0
      core/auth/signers/sha256withrsa_signer_test.go
  20. 15 0
      core/auth/validator.go
  21. 24 0
      core/auth/validators/null_validator.go
  22. 352 0
      core/auth/validators/validator_test.go
  23. 38 0
      core/auth/validators/wechat_pay_notify_validator.go
  24. 42 0
      core/auth/validators/wechat_pay_response_validator.go
  25. 129 0
      core/auth/validators/wechat_pay_validator.go
  26. 12 0
      core/auth/verifier.go
  27. 37 0
      core/auth/verifiers/sha256withrsa_combined_verifier.go
  28. 50 0
      core/auth/verifiers/sha256withrsa_pubkey_verifier.go
  29. 154 0
      core/auth/verifiers/sha256withrsa_pubkey_verifier_test.go
  30. 73 0
      core/auth/verifiers/sha256withrsa_verifier.go
  31. 198 0
      core/auth/verifiers/sha256withrsa_verifier_test.go
  32. 76 0
      core/certificate_map.go
  33. 32 0
      core/certificate_visitor.go
  34. 14 0
      core/cipher/cipher.go
  35. 31 0
      core/cipher/ciphers/context.go
  36. 225 0
      core/cipher/ciphers/wechat_pay_cipher.go
  37. 353 0
      core/cipher/ciphers/wechat_pay_cipher_test.go
  38. 11 0
      core/cipher/decryptor.go
  39. 23 0
      core/cipher/decryptors/mock_decryptor.go
  40. 29 0
      core/cipher/decryptors/wechat_pay_decryptor.go
  41. 71 0
      core/cipher/decryptors/wechat_pay_decryptor_test.go
  42. 14 0
      core/cipher/encryptor.go
  43. 28 0
      core/cipher/encryptors/mock_encryptor.go
  44. 51 0
      core/cipher/encryptors/wechat_pay_encryptor.go
  45. 193 0
      core/cipher/encryptors/wechat_pay_encryptor_test.go
  46. 44 0
      core/cipher/encryptors/wechat_pay_pubkey_encryptor.go
  47. 507 0
      core/client.go
  48. 125 0
      core/client_example_test.go
  49. 449 0
      core/client_test.go
  50. 57 0
      core/consts/const.go
  51. 210 0
      core/downloader/downloader.go
  52. 245 0
      core/downloader/downloader_mgr.go
  53. 36 0
      core/downloader/downloader_mgr_singleton.go
  54. 55 0
      core/downloader/downloader_mgr_test.go
  55. 76 0
      core/downloader/downloader_test.go
  56. 92 0
      core/downloader/example_test.go
  57. 182 0
      core/downloader/mock_download_server_test.go
  58. 164 0
      core/downloader/models.go
  59. 57 0
      core/error.go
  60. 44 0
      core/notify/example_test.go
  61. 174 0
      core/notify/notify.go
  62. 35 0
      core/notify/notify_request.go
  63. 376 0
      core/notify/notify_test.go
  64. 119 0
      core/option/auth_cipher_option.go
  65. 4 0
      core/option/doc.go
  66. 120 0
      core/option/option.go
  67. 30 0
      core/settings.go
  68. 40 0
      core/type.go
  69. 17 0
      docs/cashcoupons/AvailableMerchantCollection.md
  70. 17 0
      docs/cashcoupons/AvailableSingleitemCollection.md
  71. 39 0
      docs/cashcoupons/BackgroundColor.md
  72. 178 0
      docs/cashcoupons/CallBackUrlApi.md
  73. 14 0
      docs/cashcoupons/Callback.md
  74. 14 0
      docs/cashcoupons/CardLimitation.md
  75. 25 0
      docs/cashcoupons/Coupon.md
  76. 276 0
      docs/cashcoupons/CouponApi.md
  77. 16 0
      docs/cashcoupons/CouponCollection.md
  78. 22 0
      docs/cashcoupons/CouponRule.md
  79. 24 0
      docs/cashcoupons/CreateCouponStockRequest.md
  80. 14 0
      docs/cashcoupons/CreateCouponStockResponse.md
  81. 14 0
      docs/cashcoupons/CutTypeMsg.md
  82. 15 0
      docs/cashcoupons/DeductBalanceMethod.md
  83. 15 0
      docs/cashcoupons/FavorAvailableTime.md
  84. 15 0
      docs/cashcoupons/FixedAvailableTime.md
  85. 14 0
      docs/cashcoupons/FixedValueStockMsg.md
  86. 15 0
      docs/cashcoupons/FormFile.md
  87. 14 0
      docs/cashcoupons/ImageMeta.md
  88. 17 0
      docs/cashcoupons/JumpTarget.md
  89. 16 0
      docs/cashcoupons/ListAvailableMerchantsRequest.md
  90. 16 0
      docs/cashcoupons/ListAvailableSingleitemsRequest.md
  91. 21 0
      docs/cashcoupons/ListCouponsByFilterRequest.md
  92. 18 0
      docs/cashcoupons/ListStocksRequest.md
  93. 14 0
      docs/cashcoupons/MediaImageRequest.md
  94. 13 0
      docs/cashcoupons/MediaImageResponse.md
  95. 15 0
      docs/cashcoupons/ModifyAvailableMerchantRequest.md
  96. 14 0
      docs/cashcoupons/ModifyAvailableMerchantResponse.md
  97. 15 0
      docs/cashcoupons/ModifyAvailableSingleitemRequest.md
  98. 14 0
      docs/cashcoupons/ModifyAvailableSingleitemResponse.md
  99. 15 0
      docs/cashcoupons/ModifyStockBudgetRequest.md
  100. 14 0
      docs/cashcoupons/ModifyStockBudgetResponse.md

+ 19 - 0
.github/ISSUE_TEMPLATE/bug-and-feature-request.md

@@ -0,0 +1,19 @@
+---
+name: bug-and-feature-request
+about: bug或feature request的模版
+title: ''
+labels: ''
+assignees: xy-peng
+
+---
+
+<!--
+在这里请只反馈跟微信支付 Go 语言开发库 (wechatpay-go)的 **bug** 或 **feature request**。
+
+如果你在接入微信支付的过程中遇到了业务错误,推荐通过 [腾讯客服自助服务专区](https://kf.qq.com/product/wechatpaymentmerchant.html) 或者 [微信支付在线技术支持](https://support.pay.weixin.qq.com/online-service) 获取帮助,你也可以在微信开放社区的 [开发者专区](https://developers.weixin.qq.com/community/pay) 反馈业务问题。
+
+在反馈问题时,请提供你所使用的 Go 版本和 wechatpay-go 的版本,以及尽可能详细的日志和细节(如调用代码),以便于我们能更快的找到问题。
+-->
+
++ Go 版本:
++ wechatpay-go 版本:

+ 71 - 0
.github/workflows/codeql-analysis.yml

@@ -0,0 +1,71 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ main ]
+  pull_request:
+    # The branches below must be a subset of the branches above
+    branches: [ main ]
+  schedule:
+    - cron: '0 2 * * *'
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: read
+      security-events: write
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'go' ]
+        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
+        # Learn more:
+        # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v2
+
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v1
+      with:
+        languages: ${{ matrix.language }}
+        # If you wish to specify custom queries, you can do so here or in a config file.
+        # By default, queries listed here will override any specified in a config file.
+        # Prefix the list here with "+" to use these queries and those in the config file.
+        # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
+    # If this step fails, then you should remove it and run the build manually (see below)
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v1
+
+    # ℹ️ Command-line programs to run using the OS shell.
+    # 📚 https://git.io/JvXDl
+
+    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
+    #    and modify them (or add more) to build your code if your project
+    #    uses a compiled language
+
+    #- run: |
+    #   make bootstrap
+    #   make release
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v1

+ 71 - 0
.github/workflows/go.yml

@@ -0,0 +1,71 @@
+name: Go
+
+on:
+  push:
+    branches: [ main ]
+  pull_request:
+    branches: [ main ]
+
+jobs:
+  build:
+    name: "Build for go v${{ matrix.go }}"
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        go:
+        - "1.22"
+        - "1.21"
+        - "1.20"
+        - "1.19"
+        - "1.18"
+        - "1.17"
+        - "1.16"
+    steps:
+    - uses: actions/checkout@v4
+
+    - name: Set up Go
+      uses: actions/setup-go@v5
+      with:
+        go-version: ${{ matrix.go }}
+
+    - name: Build
+      run: go build -v ./...
+  
+  staticcheck:
+    name: "Static check"
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up Go
+      uses: actions/setup-go@v5
+      with:
+        go-version: "1.19"
+    - name: staticcheck
+      run: |
+        go install honnef.co/go/tools/cmd/staticcheck@v0.4.7 &&
+        $HOME/go/bin/staticcheck ./...
+    - name: Revive Action
+      uses: morphy2k/revive-action@v2.1.1
+      with:
+        config: .revive.toml
+
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        go:
+          - "1.22"
+          - "1.21"
+          - "1.20"
+          - "1.19"
+          - "1.18"
+          - "1.17"
+          - "1.16"
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: ${{ matrix.go }}
+      - name: Test
+        run: go test -gcflags=all=-l ./...

+ 13 - 0
.gitignore

@@ -0,0 +1,13 @@
+# Editors
+.idea
+.vscode
+*.swp
+.history
+
+# Test files
+*.test
+
+# Other
+.openapi-generator
+.openapi-generator-ignore
+.DS_Store

+ 34 - 0
.revive.toml

@@ -0,0 +1,34 @@
+ignoreGeneratedHeader = false
+severity = "warning"
+confidence = 0.8
+errorCode = 0
+warningCode = 0
+
+# Default Lint Rules
+[rule.blank-imports]
+[rule.context-as-argument]
+[rule.context-keys-type]
+[rule.dot-imports]
+[rule.error-return]
+[rule.error-strings]
+[rule.error-naming]
+[rule.exported]
+[rule.if-return]
+[rule.increment-decrement]
+[rule.var-naming]
+[rule.var-declaration]
+[rule.package-comments]
+[rule.range]
+[rule.receiver-naming]
+[rule.time-naming]
+[rule.unexported-return]
+[rule.indent-error-flow]
+[rule.errorf]
+
+# WechatPay APIv3 SDK Rules[Updating]
+[rule.line-length-limit]
+Arguments = [120]
+[rule.function-result-limit]
+Arguments = [3]
+[rule.cyclomatic]
+Arguments = [10]

+ 137 - 0
CHANGELOG.md

@@ -0,0 +1,137 @@
+# Changelog
+
+## [0.2.20] - 2024-08-20
+
+### Added
+
++ 支持使用微信支付公钥验证微信支付签名
++ 支持商家转账到零钱的 `notify_url`
+
+## [0.2.18] - 2023-10-11
+
+### Changed
+
++ 更新小店 `retailstore`
++ 更新点金计划 `goldplan`
+
+## [0.2.17] - 2023-06-29
+
+### Added
+
++ 增加点金计划 `goldplan`
++ 增加微信支付分停车 `wexinpayscoreparking`
++ 增加商家券 `merchantexclusivecoupon`
++ 增加支付有礼 `giftactivity`
++ 增加代金券 `cachcoupons`
+
+### Changed
+
++ 更新商家转账 `transferbatch`
+
+## [0.2.15] - 2022-12-12
+
+### Added
+
++ 增加微工卡 `payrollcard`
++ 增加微信支付刷码乘车 `weixinpayscanandride`
++ 增加爱心餐 `lovefeast`
+
+### Changed
+
++ 批量转账 `transferbatch` 更名为 商家转账
+
+## [0.2.9] - 2021-10-15
+
+### Fixed
+
++ 修复批量转账接口部分字段误设置为 `os.File` 的问题,包括服务商([代码](services/partnertransferbatch),[文档](docs/partnertransferbatch))与直连商户([代码](services/transferbatch),[文档](docs/transferbatch))
+
+## [0.2.8] - 2021-10-08
+
+### Added
+
++ 批量转账接口,支持服务商([代码](services/partnertransferbatch),[文档](docs/partnertransferbatch))和直连商户([代码](services/transferbatch),[文档](docs/transferbatch))。
+
+## [0.2.7] - 2021-09-14
+
+### Fixed
+
++ 修复服务商支付接口`partnerpayments`与实际契约不匹配的问题
+
+## [0.2.6] - 2021-08-26
+
+### Added
+
++ 增加分账接口`profitsharing`
+
+### Changed
+
++ 移除 Enum 类型的 UnmarshalJSON 对数据的检查,避免因为增加枚举值导致的不兼容问题
+
+## [0.2.5] - 2021-07-22
+
+### Fixed
+
++ 修复获取证书序列号时会丢弃其头部0的问题
+
+## [0.2.4] - 2021-07-21
+
+### Added
+
++ 增加服务商支付接口`partnerpayments`
+
+## [0.2.3] - 2021-07-13
+
+### Fixed
+
++ 修复`notify.Handler`读取服务端`Request`的错误
+
+## [0.2.2] - 2021-07-09
+
+### Added
+
++ 微信支付境内退款(refunddomestic)接口SDK
+
+### Changed
+
++ `BREAKING CHANGE` 将现有接口SDK中的整型参数统一为`int64`。受影响接口包括:
+  <details>
+  <summary>Click to expand!</summary>
+  
+    + payments/app
+    + payments/h5
+    + payments/jsapi
+    + payments/native
+  </details>
+
+## [0.2.1] - 2021-06-25
+
+### Added
+
++ 平台证书下载器与自动下载管理器
++ 回调通知验签、解密器
++ 敏感字段自动加解密库
++ 提供拉起支付签名计算接口
++ 一键构建Client的签名器、验签器的复合Option
++ 平台证书下载命令行工具
+
+### Changed
+
++ `core.WithXXX` 等方法移动至 `option.WithXXX`
+
+### Removed
+
++ 移除 `core.Client` 自定义 Header 选项,如需设置 Header 可使用 `client.Request` 接口发起请求
++ 移除 `core.Client` 自定义 Timeout 选项,如需设置 Timeout 可设置自定义 HTTPClient
+
+## [0.2.0] - 2021-05-31
+
+### Added
+
++ 增加微信支付支付API的SDK
++ 增加文件上传API的SDK
+
+## [0.1.0] - 2020-03-03
+
++ Initial version
+

+ 128 - 0
CODE_OF_CONDUCT.md

@@ -0,0 +1,128 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+  and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+  overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+  advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+  address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+  professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+wepayTS@tencent.com.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior,  harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.

+ 104 - 0
FAQ.md

@@ -0,0 +1,104 @@
+# 常见问题
+
+**目录**
+
+- [证书](#证书)
+- [回调](#回调)
+- [其他](#其他)
+
+## 证书
+
+### 如何下载微信支付平台证书
+
+现在本 SDK 已经提供了命令行工具供开发者使用。 
+
+首先使用 `go` 指令下载命令行工具
+```shell
+go get -u github.com/wechatpay-apiv3/wechatpay-go/cmd/wechatpay_download_certs
+```
+然后执行 `wechatpay_download_certs` 即可下载微信支付平台证书到当前目录
+```shell
+wechatpay_download_certs -m <mchID> -p <mchPrivateKeyPath> -s <mchSerialNumber> -k <mchAPIv3Key>
+```
+完整参数列表可运行 `wechatpay_download_certs -h` 查看。
+
+### 如何使用平台证书下载管理器
+
+平台证书下载管理器提供已注册商户的微信支付平台证书下载和自动更新。
+
+```go
+// GetCertificate 获取商户的某个平台证书
+func (mgr *CertificateDownloaderMgr) GetCertificate(ctx context.Context, mchID, serialNumber string) (*x509.Certificate, bool)
+
+// GetCertificateVisitor 获取某个商户的平台证书访问器
+func (mgr *CertificateDownloaderMgr) GetCertificateVisitor(mchID string) core.CertificateVisitor
+```
+
+使用前,先注册商户信息至平台证书下载管理器。
+
++ 使用 `option.WithWechatPayAutoAuthCipher` 创建 `core.Client`,自动注册。
+
++ 如果你不需要 `core.Client`,则使用 `downloader.MgrInstance().RegisterDownloaderWithPrivateKey` 手动注册。
+
+以上两种方法,在进程中调用其中之一即可,调用多次无实际意义。如果以上二者都没有调用过,查询该商户的平台证书的结果为空。
+
+如果你希望了解更多,或自行管理微信支付平台证书下载管理器的生命周期,请参阅 [`core/downloader`](core/downloader) 的代码。
+
+### 为什么收到应答中的证书序列号和发起请求的证书序列号不一致
+
+请求和应答使用[数字签名](https://zh.wikipedia.org/wiki/%E6%95%B8%E4%BD%8D%E7%B0%BD%E7%AB%A0),保证数据传递的真实、完整和不可否认。为了验签方能识别数字签名使用的密钥(特别是密钥和证书更换期间),微信支付 API v3 要求签名和相应的证书序列号一起传输。
+
++ 商户请求使用**商户API私钥**签名。商户应上送商户证书序列号。
++ 微信支付应答使用**微信支付平台私钥**签名。微信支付应答返回微信支付平台证书序列号。
+
+综上所述,请求和应答的证书序列号是不一致的。
+
+## 回调
+
+### 证书和回调解密需要的AesGcm解密在哪里?
+
+请参考 [aes.go](https://github.com/wechatpay-apiv3/wechatpay-go/blob/main/utils/aes.go) 和 [aes_test.go](https://github.com/wechatpay-apiv3/wechatpay-go/blob/main/utils/aes_test.go)。
+
+由于 SDK 已经提供了微信支付平台证书下载器`downloader.CertificateDownloader`以及回调处理器`notify.Handler`,这两者会完成所有的解析与解密工作。因此除非你想要自定义实现,否则你应该不需要用到`aes.go`中提供的方法。
+
+### 回调验签失败,返回 `crypto/rsa: verification error`
+
+该错误表示回调的证书序列号正确,但验证签名未通过,说明数据不正确。
+
+如果是真实的微信支付回调,请检查是否在 `ParseNotifyRequest` 前消费过 `Request.Body`。`Request.Body` 定义为 `io.Reader`,不支持重复读取。
+
+## 其他
+
+### 如何下载账单
+[账单下载API](https://pay.weixin.qq.com/wiki/doc/apiv3/wxpay/pay/bill/chapter3_3.shtml) 分成了两个步骤:
+1. `/v3/bill/tradebill` 获取账单下载链接和账单摘要
+2. `/v3/billdownload/file` 账单文件下载,请求需签名但应答不签名
+
+其中第二步的应答中不包含应答数字签名,无法验签,应使用`WithoutValidator()`**跳过**应答签名的校验。
+```go
+opts := []core.ClientOption{
+	option.WithMerchantCredential(mchID, mchCertificateSerialNumber, privateKey),
+	option.WithoutValidator(),
+}
+
+client, err := core.NewClient(ctx, opts...)
+```
+
+> **注意**:第一步中应正常对应答验证签名
+> 
+> **注意**:开发者在下载文件之后,应使用第一步获取的账单摘要校验文件的完整性
+
+### 如何查看 HTTP 请求的 Request 信息
+
+不论是使用 `Client` 的 HTTP 方法(`Get/Post/Put/Delete`等)直接发送 HTTP 请求,还是使用服务API对应的SDK发起请求,均会返回 `*core.APIResult` 结构。
+该结构中包含了本次发起 HTTP 请求的 `http.Request` 对象和微信支付应答的 `http.Response` 对象。
+```golang
+var (
+	request *http.Request
+	response *http.Response
+)
+result, err = client.Get(ctx, "")
+
+request = result.Request
+response = result.Response 
+```

+ 201 - 0
LICENSE

@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 543 - 0
README.md

@@ -0,0 +1,543 @@
+# 微信支付 API v3 Go SDK
+
+[![GoDoc](http://img.shields.io/badge/godoc-reference-blue.svg)](https://pkg.go.dev/github.com/wechatpay-apiv3/wechatpay-go)
+[![licence](https://badgen.net/github/license/wechatpay-apiv3/wechatpay-go)](https://github.com/wechatpay-apiv3/wechatpay-go/blob/main/LICENSE)
+
+[微信支付 APIv3](https://wechatpay-api.gitbook.io/wechatpay-api-v3/) 官方Go语言客户端代码库。
+
+## 功能介绍
+
+1. 接口 SDK。详见 [接口介绍](services)。
+2. HTTP 客户端 `core.Client`,支持请求签名和应答验签。如果 SDK 未支持你需要的接口,请用此客户端发起请求。
+3. 回调通知处理库 `core/notify`,支持微信支付回调通知的验签和解密。详见 [回调通知验签与解密](#回调通知的验签与解密)。
+4. 证书下载、[敏感信息加解密](#敏感信息加解密) 等辅助能力。
+
+### 兼容性
+
+当前版本为测试版本,微信支付会尽量保持向后兼容。但可能因为可用性或易用性,同历史版本存在不兼容。如果你使用版本 `<= v0.2.2`,升级前请参考 [升级指南](UPGRADING.md)。
+
+## 快速开始
+
+### 安装
+
+#### 1、使用 Go Modules 管理你的项目
+
+如果你的项目还不是使用 Go Modules 做依赖管理,在项目根目录下执行:
+
+```shell
+go mod init
+```
+
+#### 2、无需 clone 仓库中的代码,直接在项目目录中执行
+
+```shell
+go get -u github.com/wechatpay-apiv3/wechatpay-go
+```
+
+来添加依赖,完成 `go.mod` 修改与 SDK 下载。
+
+### 发送请求
+
+先初始化一个 `core.Client` 实例,再向微信支付发送请求。
+
+```go
+package main
+
+import (
+	"context"
+	"log"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/option"
+	"github.com/wechatpay-apiv3/wechatpay-go/services/certificates"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+func main() {
+	var (
+		mchID                      string = "190000****"                                // 商户号
+		mchCertificateSerialNumber string = "3775B6A45ACD588826D15E583A95F5DD********"  // 商户证书序列号
+		mchAPIv3Key                string = "2ab9****************************"          // 商户APIv3密钥
+	)
+
+	// 使用 utils 提供的函数从本地文件中加载商户私钥,商户私钥会用来生成请求的签名
+	mchPrivateKey, err := utils.LoadPrivateKeyWithPath("/path/to/merchant/apiclient_key.pem")
+	if err != nil {
+		log.Fatal("load merchant private key error")
+	}
+
+	ctx := context.Background()
+	// 使用商户私钥等初始化 client,并使它具有自动定时获取微信支付平台证书的能力
+	opts := []core.ClientOption{
+		option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
+	}
+	client, err := core.NewClient(ctx, opts...)
+	if err != nil {
+		log.Fatalf("new wechat pay client err:%s", err)
+	}
+	
+	// 发送请求,以下载微信支付平台证书为例
+	// https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay5_1.shtml
+	svc := certificates.CertificatesApiService{Client: client}
+	resp, result, err := svc.DownloadCertificates(ctx)
+	log.Printf("status=%d resp=%s", result.Response.StatusCode, resp)
+}
+```
+
+`resp` 是反序列化(UnmarshalJSON)后的应答。上例中是 `services/certificates` 包中的 `*certificates.Certificate`。
+
+`result` 是 `*core.APIResult` 实例,包含了完整的请求报文 `*http.Request` 和应答报文 `*http.Response`。
+
+#### 名词解释
+
++ **商户 API 证书**,是用来证实商户身份的。证书中包含商户号、证书序列号、证书有效期等信息,由证书授权机构(Certificate Authority ,简称 CA)签发,以防证书被伪造或篡改。如何获取请见 [商户 API 证书](https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/zheng-shu#shang-hu-api-zheng-shu) 。
+
++ **商户 API 私钥**。商户申请商户 API 证书时,会生成商户私钥,并保存在本地证书文件夹的文件 apiclient_key.pem 中。
+
+> :warning: 不要把私钥文件暴露在公共场合,如上传到 Github,写在客户端代码等。
+
++ **微信支付平台证书**。微信支付平台证书是指由微信支付负责申请的,包含微信支付平台标识、公钥信息的证书。商户使用微信支付平台证书中的公钥验证应答签名。获取微信支付平台证书需通过 [获取平台证书列表](https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/zheng-shu#ping-tai-zheng-shu) 接口下载。
++ **证书序列号**。每个证书都有一个由 CA 颁发的唯一编号,即证书序列号。扩展阅读 [如何查看证书序列号](https://wechatpay-api.gitbook.io/wechatpay-api-v3/chang-jian-wen-ti/zheng-shu-xiang-guan#ru-he-cha-kan-zheng-shu-xu-lie-hao) 。
++ **微信支付 APIv3 密钥**,是在回调通知和微信支付平台证书下载接口中,为加强数据安全,对关键信息 `AES-256-GCM` 加密时使用的对称加密密钥。
+
+## 更多示例
+
+### 以 [JSAPI下单](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_1_1.shtml) 为例
+
+```go
+import (
+	"log"
+	"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
+)
+
+svc := jsapi.JsapiApiService{Client: client}
+// 得到prepay_id,以及调起支付所需的参数和签名
+resp, result, err := svc.PrepayWithRequestPayment(ctx,
+	jsapi.PrepayRequest{
+		Appid:       core.String("wxd678efh567hg6787"),
+		Mchid:       core.String("1900009191"),
+		Description: core.String("Image形象店-深圳腾大-QQ公仔"),
+		OutTradeNo:  core.String("1217752501201407033233368018"),
+		Attach:      core.String("自定义数据说明"),
+		NotifyUrl:   core.String("https://www.weixin.qq.com/wxpay/pay.php"),
+		Amount: &jsapi.Amount{
+			Total: core.Int64(100),
+		},
+		Payer: &jsapi.Payer{
+			Openid: core.String("oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"),
+		},
+	},
+)
+
+if err == nil {
+	log.Println(resp)
+} else {
+	log.Println(err)
+}
+```
+
+### 以 [查询订单](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_1_2.shtml) 为例
+
+```go
+import (
+	"log"
+	"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
+)
+
+svc := jsapi.JsapiApiService{Client: client}
+
+resp, result, err := svc.QueryOrderById(ctx,
+	jsapi.QueryOrderByIdRequest{
+		TransactionId: core.String("4200000985202103031441826014"),
+		Mchid:         core.String("1900009191"),
+	},
+)
+
+if err == nil {
+	log.Println(resp)
+} else {
+	log.Println(err)
+}
+
+```
+
+### 以 [图片上传API](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter2_1_1.shtml) 为例
+
+```go
+import (
+	"os"
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
+	"github.com/wechatpay-apiv3/wechatpay-go/services/fileuploader"
+)
+
+file, err := os.Open("resource/demo.jpg")
+defer file.Close()
+if err != nil {
+	return err
+}
+
+svc := fileuploader.ImageUploader{Client: client}
+resp, result, err := svc.Upload(ctx, file, "demo.jpg", consts.ImageJPG)
+
+```
+
+### 示例程序
+
+为了方便开发者快速上手,微信支付给每个服务生成了示例代码 `api_xx_example_test.go`。请按需查阅。例如:
+
++ [api_jsapi_example_test.go](services/payments/jsapi/api_jsapi_example_test.go)
++ [api_refunds_example_test.go](services/refunddomestic/api_refunds_example_test.go)
+
+## 发送 HTTP 请求
+
+如果 SDK 还未支持你需要的接口,使用 `core.Client` 的 `GET`、`POST` 等方法发送 HTTP 请求,而不用关注签名、验签等逻辑。
+
+以 [下载微信支付平台证书](https://wechatpay-api.gitbook.io/wechatpay-api-v3/jie-kou-wen-dang/ping-tai-zheng-shu) 为例:
+
+```go
+result, err := client.Get(ctx, "https://api.mch.weixin.qq.com/v3/certificates")
+```
+
+使用 `core.Client` 发送 HTTP 请求后会得到 `*core.APIResult` 实例。
+
+## 错误处理
+
+以下情况,SDK 发送请求会返回 `error`:
+
++ HTTP 网络错误,如应答接收超时或网络连接失败
++ 客户端失败,如生成签名失败
++ 服务器端返回了**非** `2xx` HTTP 状态码
++ 应答签名验证失败
+
+为了方便使用,SDK 将服务器返回的 `4xx` 和 `5xx` 错误,转换成了 `APIError`。
+
+```go
+// 错误处理示例
+result, err := client.Get(ctx, "https://api.mch.weixin.qq.com/v3/certificates")
+if err != nil {
+	if core.IsAPIError(err, "INVALID_REQUEST") { 
+		// 处理无效请求 
+	}
+	// 处理的其他错误
+}
+```
+
+## 回调通知的验签与解密
+
+1. 使用微信支付平台证书(验签)和商户 APIv3 密钥(解密)初始化 `notify.Handler`
+2. 调用 `handler.ParseNotifyRequest` 验签,并解密报文。
+
+### 初始化
+
++ 方法一(大多数场景):先手动注册下载器,再获取微信平台证书访问器。
+
+适用场景: 仅需要对回调通知验证签名并解密的场景。例如,基础支付的回调通知。
+
+```go
+ctx := context.Background()
+// 1. 使用 `RegisterDownloaderWithPrivateKey` 注册下载器
+err := downloader.MgrInstance().RegisterDownloaderWithPrivateKey(ctx, mchPrivateKey, mchCertificateSerialNumber, mchID, mchAPIV3Key)
+// 2. 获取商户号对应的微信支付平台证书访问器
+certificateVisitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
+// 3. 使用证书访问器初始化 `notify.Handler`
+handler := notify.NewNotifyHandler(mchAPIv3Key, verifiers.NewSHA256WithRSAVerifier(certificateVisitor))
+```
+
++ 方法二:像 [发送请求](#发送请求) 那样使用 `WithWechatPayAutoAuthCipher` 初始化 `core.Client`,然后再用client进行接口调用。
+
+适用场景:需要对回调通知验证签名并解密,并且后续需要使用 Client 的场景。例如,电子发票的回调通知,验签与解密后还需要通过 Client 调用用户填写抬头接口。
+
+```go
+ctx := context.Background()
+// 1. 使用商户私钥等初始化 client,并使它具有自动定时获取微信支付平台证书的能力
+opts := []core.ClientOption{
+	option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
+}
+client, err := core.NewClient(ctx, opts...)	
+// 2. 获取商户号对应的微信支付平台证书访问器
+certificateVisitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
+// 3. 使用证书访问器初始化 `notify.Handler`
+handler := notify.NewNotifyHandler(mchAPIv3Key, verifiers.NewSHA256WithRSAVerifier(certificateVisitor))
+// 4. 使用client进行接口调用
+// ...
+```
+
++ 方法三:使用本地的微信支付平台证书和商户 APIv3 密钥初始化 `Handler`。
+
+适用场景:首次通过工具下载平台证书到本地,后续使用本地管理的平台证书进行验签与解密。
+
+```go
+// 1. 初始化商户API v3 Key及微信支付平台证书
+mchAPIv3Key := "<your apiv3 key>"
+wechatPayCert, err := utils.LoadCertificate("<your wechat pay certificate>")
+// 2. 使用本地管理的微信支付平台证书获取微信支付平台证书访问器
+certificateVisitor := core.NewCertificateMapWithList([]*x509.Certificate{wechatPayCert})
+// 3. 使用apiv3 key、证书访问器初始化 `notify.Handler`
+handler := notify.NewNotifyHandler(mchAPIv3Key, verifiers.NewSHA256WithRSAVerifier(certificateVisitor))
+```
+
+建议:为了正确使用平台证书下载管理器,你应阅读并理解 [如何使用平台证书下载管理器](FAQ.md#如何使用平台证书下载管理器)。
+
+### 验签与解密
+
+将支付回调通知中的内容,解析为 `payments.Transaction`。
+
+```go
+transaction := new(payments.Transaction)
+notifyReq, err := handler.ParseNotifyRequest(context.Background(), request, transaction)
+// 如果验签未通过,或者解密失败
+if err != nil {
+	fmt.Println(err)
+	return
+}
+// 处理通知内容
+fmt.Println(notifyReq.Summary)
+fmt.Println(transaction.TransactionId)
+```
+
+将 SDK 未支持的回调消息体,解析至 `map[string]interface{}`。
+
+```go
+content := make(map[string]interface{})
+notifyReq, err := handler.ParseNotifyRequest(context.Background(), request, &content)
+// 如果验签未通过,或者解密失败
+if err != nil {
+	fmt.Println(err)
+	return
+}
+// 处理通知内容
+fmt.Println(notifyReq.Summary)
+fmt.Println(content)
+```
+
+## 敏感信息加解密
+
+为了保证通信过程中敏感信息字段(如用户的住址、银行卡号、手机号码等)的机密性,
+
++ 微信支付要求加密上行的敏感信息
++ 微信支付会加密下行的敏感信息
+
+详见 [接口规则 - 敏感信息加解密](https://wechatpay-api.gitbook.io/wechatpay-api-v3/qian-ming-zhi-nan-1/min-gan-xin-xi-jia-mi)。
+
+### (推荐)使用敏感信息加解密器
+
+敏感信息加解密器 `cipher.Cipher` 能根据 API 契约自动处理敏感信息:
+
++ 发起请求时,开发者设置原文,加密器自动加密敏感信息,并设置 `Wechatpay-Serial` 请求头
++ 收到应答时,解密器自动解密敏感信息,开发者得到原文
+
+使用敏感信息加解密器,只需通过 `option.WithWechatPayCipher` 为 `core.Client` 添加加解密器:
+
+```go
+client, err := core.NewClient(
+    context.Background(),
+// 一次性设置 签名/验签/敏感字段加解密,并注册 平台证书下载器,自动定时获取最新的平台证书
+    option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
+    option.WithWechatPayCipher(
+        encryptors.NewWechatPayEncryptor(downloader.MgrInstance().GetCertificateVisitor(mchID)),
+        decryptors.NewWechatPayDecryptor(mchPrivateKey),
+    ),
+)
+```
+
+### 使用加解密算法工具包
+
+#### 步骤一:获取微信支付平台证书
+
+请求的敏感信息,使用微信支付平台证书中的公钥加密。推荐 [使用平台证书下载管理器](FAQ.md#如何使用平台证书下载管理器) 获取微信支付平台证书,或者 [下载平台证书](FAQ.md#如何下载微信支付平台证书)。
+
+#### 步骤二:加解密
+
+使用工具包 [utils](utils) 中的函数,手动对敏感信息加解密。
+
+```go
+package utils
+
+// EncryptOAEPWithPublicKey 使用公钥加密
+func EncryptOAEPWithPublicKey(message string, publicKey *rsa.PublicKey) (ciphertext string, err error)
+// EncryptOAEPWithCertificate 使用证书中的公钥加密
+func EncryptOAEPWithCertificate(message string, certificate *x509.Certificate) (ciphertext string, err error)
+
+// DecryptOAEP 使用私钥解密
+func DecryptOAEP(ciphertext string, privateKey *rsa.PrivateKey) (message string, err error)
+```
+
+[rsa_crypto_test.go](utils/rsa_crypto_test.go) 中演示了如何使用以上函数做敏感信息加解密。
+
+#### 步骤三:设置 `Wechatpay-Serial` 请求头
+
+请求的敏感信息加密后,在 HTTP 请求头中添加微信支付平台证书序列号 `Wechatpay-Serial`。该序列号用于告知微信支付加密使用的证书。
+
+使用 `core.Client` 的 `Request` 方法来传输自定义 HTTPHeader。
+
+```go
+// Request 向微信支付发送请求
+//
+// 相比于 Get / Post / Put / Patch / Delete 方法,本方法支持设置更多内容
+// 特别地,如果需要为当前请求设置 Header,应使用本方法
+func (client *Client) Request(
+	ctx context.Context,
+	method, requestPath string,
+	headerParams http.Header,
+	queryParams url.Values,
+	postBody interface{},
+	contentType string,
+) (result *APIResult, err error)
+
+// 示例代码
+// 微信支付平台证书序列号,对应加密使用的私钥
+header.Add("Wechatpay-Serial", "5157F09EFDC096DE15EBE81A47057A72*******")
+result, err := client.Request(
+	ctx,
+	"POST",
+	"https://api.mch.weixin.qq.com/v3/profitsharing/receivers/add",
+	header,
+	nil,
+	body,
+	"application/json")
+
+```
+
+## 自定义签名生成器与验证器
+
+当默认的本地签名和验签方式不适合你的系统时,实现 `Signer` 或者 `Verifier` 来定制签名和验签。
+
+比如,你把商户私钥集中存储,业务系统通过远程调用获得请求签名。
+
+```golang
+// 签名器
+type CustomSigner struct {
+}
+
+func (s *CustomSigner) Sign(ctx context.Context, message string) (*auth.SignatureResult, error) {
+    // TODO: 远程调用获取签名信息
+    return &auth.SignatureResult{MchID: "xxx", MchCertificateSerialNo: "xxx", Signature: "xxx"}, nil
+}
+
+// 校验器
+type CustomVerifier struct {
+}
+
+func (v *CustomVerifier) Verify(ctx context.Context, serial, message, signature string) error {
+    // TODO: 远程调用验签
+    return nil
+}
+```
+
+当你需要使用自定的签名器和校验器时,这样创建客户端
+
+```golang
+package core_test
+
+import (
+	"context"
+
+	"path/to/your/custom_signer"
+	"path/to/your/custom_verifier"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/credentials"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/validators"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/option"
+)
+
+func NewCustomClient(ctx context.Context, mchID string) (*core.Client, error) {
+	signer := &custom_signer.CustomSigner{
+		// ... 
+	}
+	verifier := &custom_verifier.CustomVerifier{
+		// ...
+	}
+
+	opts := []core.ClientOption{
+		option.WithSigner(signer),
+		option.WithVerifier(verifier),
+	}
+
+	return core.NewClient(ctx, opts...)
+}
+```
+
+### 使用公钥验证微信支付签名
+
+如果你的商户是全新入驻,且仅可使用微信支付的公钥验证应答和回调的签名,请使用微信支付公钥和公钥 ID 初始化。
+
+```go
+var (
+	wechatpayPublicKeyID       string = "00000000000000000000000000000000"          // 微信支付公钥ID
+)
+
+wechatpayPublicKey, err = utils.LoadPublicKeyWithPath("/path/to/wechatpay/pub_key.pem")
+if err != nil {
+	panic(fmt.Errorf("load wechatpay public key err:%s", err.Error()))
+}
+    
+// 初始化 Client
+opts := []core.ClientOption{
+	option.WithWechatPayPublicKeyAuthCipher(
+		mchID,
+		mchCertificateSerialNumber, mchPrivateKey,
+		wechatpayPublicKeyID, wechatpayPublicKey),
+}
+client, err := core.NewClient(ctx, opts...)
+
+// 初始化 notify.Handler
+handler := notify.NewNotifyHandler(
+	mchAPIv3Key, 
+	verifiers.NewSHA256WithRSAPubkeyVerifier(wechatpayPublicKeyID, *wechatPayPublicKey))
+```
+
+如果你既有微信支付平台证书,又有公钥。那么,你可以在商户平台自助地从微信支付平台证书切换到公私钥,或者反过来。
+在切换期间,回调要同时支持使用平台证书和公钥的验签。 
+
+请参考下文,使用微信平台证书访问器和公钥一起初始化 `NotifyHandler`。
+
+```go
+// 初始化 notify.Handler
+handler := notify.NewNotifyHandler(
+	mchAPIv3Key,
+	verifiers.NewSHA256WithRSACombinedVerifier(certificateVisitor, wechatpayPublicKeyID, *wechatPayPublicKey))
+```
+
+## 常见问题
+
+常见问题请见 [FAQ.md](FAQ.md)。
+
+## 如何参与开发
+
+微信支付欢迎来自社区的开发者贡献你们的想法和代码。请你在提交 PR 之前,先提一个对应的 issue 说明以下内容:
+
++ 背景(如,遇到的问题)和目的
++ **着重**说明你的想法
++ 通过代码或者其他方式,简要的说明是如何实现的,或者它会是如何使用
++ 是否影响现有的接口
+
+[#35](https://github.com/wechatpay-apiv3/wechatpay-go/issues/35) 是一个很好的参考。
+
+### 测试
+
+开发者提交的代码,应能通过本 SDK 所有的测试用例。
+
+SDK 在单元测试中使用了 [agiledragon/gomonkey](https://github.com/agiledragon/gomonkey) 和 [stretchr/testify](https://github.com/stretchr/testify),测试前请确认相关的依赖。使用以下命令获取所有的依赖。
+
+```bash
+go get -t -v
+```
+
+由于 `gomonkey` 的原因,在执行测试用例时需要携带参数 `-gcflags=all=-l`。使用以下命令发起测试。
+
+```bash
+go test -gcflags=all=-l ./...
+```
+
+## 联系微信支付
+
+如果你发现了 BUG,或者需要的功能还未支持,或者有任何疑问、建议,欢迎通过 [issue](https://github.com/wechatpay-apiv3/wechatpay-go/issues) 反馈。
+
+也欢迎访问微信支付的 [开发者社区](https://developers.weixin.qq.com/community/pay)。
+
+### 帮助微信支付改进 SDK
+
+为了向广大开发者提供更好的使用体验,微信支付诚挚邀请您反馈使用微信支付 APIv3 SDK中的感受。
+您的反馈将对改进 SDK 大有帮助,[点击参与问卷调查](https://wj.qq.com/s2/8774719/ef10/)。

+ 20 - 0
SECURITY.md

@@ -0,0 +1,20 @@
+# Security Policy
+
+## Supported Versions
+
+| Version | Supported          |
+| ------- | ------------------ |
+| 0.2.x   | :white_check_mark: |
+| 0.1.x   | :x:                |
+
+## Reporting a Vulnerability
+
+Please do not open GitHub issues or pull requests - this makes the problem immediately visible to everyone, including malicious actors.
+
+Security issues in this open source project can be safely reported to wepayTS(at)tencent.com.
+
+## 报告漏洞
+
+请不要使用 GitHub issues 或 pull request —— 这会让漏洞立即暴露给所有人,包括恶意人员。
+
+请将本开源项目的安全问题报告给 wepayTS(at)tencent.com.

+ 49 - 0
UPGRADING.md

@@ -0,0 +1,49 @@
+# 升级指南
+
+## v0.2.2
+
+版本 `v0.2.2` 中我们包含了一个重大更新内容:将现有接口SDK中的整型参数统一为`int64`。
+这一行为的目的是规范SDK中对整型参数的实现,避免因为未来可能的`int32 -> int64`的字段升级导致大规模兼容性问题。
+
+此次升级会导致 `payments` 下4个API接口的SDK的兼容性问题,建议开发者以如下方式对自己的代码进行更新。
+
+### 1. 升级依赖
+1. 在你的模块目录下执行 `go get -u github.com/wechatpay-apiv3/wechatpay-go@v0.2.7` 升级依赖。
+2. (正常情况下该步骤会自动完成)修改模块 `go.mod` 文件中依赖的 `github.com/wechatpay-apiv3/wechatpay-go` 至 `v0.2.7`版本。
+
+### 2. 定位需要修改的代码
+在项目根目录下执行`go build ./...`可以递归检查代码中的编译错误,即可快速定位到需要修改的代码。
+
+### 3. 对请求构建代码进行更新
+对于请求 `payments` 接口的数据,可以在设置参数时使用`int64(xxx)`进行类型转换。当然也可以将请求链路上的类型从`int32`更新为`int64`。
+```go
+req := jsapi.PrepayRequest{}
+// 升级前
+req.Amount = &jsapi.Amount{
+	Currency: core.String("CNY"),
+	Total:    &totalInt32,
+}
+// 升级后
+totalInt64 := int64(totalInt32)
+req.Amount = &jsapi.Amount{
+	Currency: core.String("CNY"),
+	Total:    &totalInt64,
+}
+```
+
+### 4. 对应答处理代码进行更新
+对于应答结果的处理,我们不建议将返回结果中的`int64`强制类型转换为`int32`,而是建议将后续处理链路中的类型从`int32`更新为`int64`。
+这样变更可能会更复杂,但是安全性更好,避免因为数据溢出导致错误。
+```go
+// 升级前
+func GetTransactionTotal(resp *payments.Transaction) int32 {
+	return *resp.Amount.Total
+}
+// 升级后
+func GetTransactionTotal(resp *payments.Transaction) int64 {
+	return *resp.Amount.Total
+}
+```
+
+### 5. 更新你的测试用例代码并测试
+如果你有针对 `payments` 编写测试用例,你可能需要对测试用例代码进行更新,并重新测试确保一切正常。

+ 191 - 0
cmd/wechatpay_download_certs/wechatpay_download_certs.go

@@ -0,0 +1,191 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package main
+
+import (
+	"context"
+	"crypto/x509"
+	"flag"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/option"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+var (
+	mchID             string
+	mchSerialNo       string
+	mchPrivateKeyPath string
+	mchAPIv3Key       string
+
+	wechatPayCertificatePath string
+	outputPath               string
+)
+
+const errCodeParamError = 1
+const errCodeRunError = 2
+
+func init() {
+	flag.StringVar(&mchID, "m", "", "【必传】`商户号`")
+	flag.StringVar(&mchSerialNo, "s", "", "【必传】`商户证书序列号`")
+	flag.StringVar(&mchPrivateKeyPath, "p", "", "【必传】`商户私钥路径`")
+	flag.StringVar(&mchAPIv3Key, "k", "", "【必传】`商户APIv3密钥`")
+
+	flag.StringVar(&wechatPayCertificatePath, "c", "", "【可选】`商户平台证书路径`,用于验签。省略则跳过验签")
+	flag.StringVar(&outputPath, "o", "./", "【可选】`证书下载保存目录`")
+}
+
+func main() {
+	flag.Parse()
+	flag.Usage = printUsageAndExit
+
+	if err := checkArgs(); err != nil {
+		reportError("参数有误:", err)
+		printUsageAndExit()
+	}
+
+	ctx := context.Background()
+	client, err := createClient(ctx)
+	if err != nil {
+		reportError("初始化失败:", err)
+		os.Exit(errCodeRunError)
+	}
+
+	d, err := downloader.NewCertificateDownloaderWithClient(ctx, client, mchAPIv3Key)
+	if err != nil {
+		reportError("下载证书失败:", err)
+		os.Exit(errCodeRunError)
+	}
+
+	err = saveCertificates(ctx, d)
+	if err != nil {
+		reportError("保存证书失败:", err)
+		os.Exit(errCodeRunError)
+	}
+
+	os.Exit(0)
+}
+
+func reportError(message string, err error) {
+	_, _ = fmt.Fprintf(os.Stderr, message+" %v\n", err)
+}
+
+func printUsageAndExit() {
+	_, _ = fmt.Fprintf(os.Stderr, "usage of wechatpay_download_certs:\n")
+	flag.PrintDefaults()
+	os.Exit(errCodeParamError)
+}
+
+type paramError struct {
+	name    string
+	value   string
+	message string
+}
+
+// Error 输出 paramError
+func (e paramError) Error() string {
+	if e.value != "" {
+		return fmt.Sprintf("%v(%v) %v", e.name, e.value, e.message)
+	}
+	return fmt.Sprintf("%v %v", e.name, e.message)
+}
+
+// revive:disable:cyclomatic
+func checkArgs() error {
+	if mchID == "" {
+		return paramError{"商户号", mchID, "必传"}
+	}
+
+	if mchSerialNo == "" {
+		return paramError{"商户证书序列号", mchSerialNo, "必传"}
+	}
+
+	if mchPrivateKeyPath == "" {
+		return paramError{"商户私钥路径", mchPrivateKeyPath, "必传"}
+	}
+
+	fileInfo, err := os.Stat(mchPrivateKeyPath)
+	if err != nil {
+		return paramError{"商户私钥路径", mchPrivateKeyPath, fmt.Sprintf("有误: %v", err)}
+	}
+	if fileInfo.IsDir() {
+		return paramError{"商户私钥路径", mchPrivateKeyPath, "不是合法的文件路径"}
+	}
+
+	if mchAPIv3Key == "" {
+		return paramError{"商户APIv3密钥", mchAPIv3Key, "必传"}
+	}
+
+	if wechatPayCertificatePath != "" {
+		fileInfo, err := os.Stat(wechatPayCertificatePath)
+		if err != nil {
+			return paramError{"商户平台证书路径", wechatPayCertificatePath, fmt.Sprintf("有误:%v", err)}
+		}
+		if fileInfo.IsDir() {
+			return paramError{"商户平台证书路径", wechatPayCertificatePath, "不是合法的文件路径"}
+		}
+	}
+
+	err = os.MkdirAll(outputPath, os.ModePerm)
+	if err != nil {
+		return paramError{"证书下载保存目录", outputPath, fmt.Sprintf("创建失败:%v", err)}
+	}
+
+	return nil
+}
+
+// revive:enable:cyclomatic
+
+func saveCertificates(ctx context.Context, d *downloader.CertificateDownloader) error {
+	for serialNo, certContent := range d.ExportAll(ctx) {
+		outputFilePath := filepath.Join(outputPath, fmt.Sprintf("wechatpay_%v.pem", serialNo))
+
+		f, err := os.Create(outputFilePath)
+		if err != nil {
+			return fmt.Errorf("创建证书文件`%v`失败:%v", outputFilePath, err)
+		}
+
+		_, err = f.WriteString(certContent + "\n")
+		if err != nil {
+			return fmt.Errorf("写入证书到`%v`失败: %v", outputFilePath, err)
+		}
+
+		fmt.Printf("写入证书到`%v`成功\n", outputFilePath)
+	}
+	return nil
+}
+
+func createClient(ctx context.Context) (*core.Client, error) {
+	privateKey, err := utils.LoadPrivateKeyWithPath(mchPrivateKeyPath)
+	if err != nil {
+		return nil, fmt.Errorf("商户私钥有误:%v", err)
+	}
+
+	var client *core.Client
+	if wechatPayCertificatePath != "" {
+		wechatPayCertificate, err := utils.LoadCertificateWithPath(wechatPayCertificatePath)
+		if err != nil {
+			return nil, fmt.Errorf("平台证书有误:%v", err)
+		}
+		client, err = core.NewClient(
+			ctx, option.WithMerchantCredential(mchID, mchSerialNo, privateKey),
+			option.WithWechatPayCertificate([]*x509.Certificate{wechatPayCertificate}),
+		)
+		if err != nil {
+			return nil, fmt.Errorf("创建 Client 失败:%v", err)
+		}
+	} else {
+		client, err = core.NewClient(
+			ctx, option.WithMerchantCredential(mchID, mchSerialNo, privateKey), option.WithoutValidator(),
+		)
+		if err != nil {
+			return nil, fmt.Errorf("创建 Client 失败:%v", err)
+		}
+	}
+
+	return client, nil
+}

+ 11 - 0
core/auth/credential.go

@@ -0,0 +1,11 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package auth 微信支付 API v3 Go SDK 安全验证相关接口
+package auth
+
+import "context"
+
+// Credential 请求报文头 Authorization 信息生成器
+type Credential interface {
+	GenerateAuthorizationHeader(ctx context.Context, method, canonicalURL, signBody string) (string, error)
+}

+ 48 - 0
core/auth/credentials/wechat_pay_credential.go

@@ -0,0 +1,48 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package credentials 微信支付 API v3 Go SDK 请求报文头 Authorization 信息生成器
+package credentials
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+// WechatPayCredentials 微信支付请求报文头 Authorization 信息生成器
+type WechatPayCredentials struct {
+	Signer auth.Signer // 数字签名生成器
+}
+
+// GenerateAuthorizationHeader 生成请求报文头中的 Authorization 信息,详见:
+// https://wechatpay-api.gitbook.io/wechatpay-api-v3/qian-ming-zhi-nan-1/qian-ming-sheng-cheng
+func (c *WechatPayCredentials) GenerateAuthorizationHeader(
+	ctx context.Context, method, canonicalURL, signBody string,
+) (string, error) {
+	if c.Signer == nil {
+		return "", fmt.Errorf("you must init WechatPayCredentials with signer")
+	}
+	nonce, err := utils.GenerateNonce()
+	if err != nil {
+		return "", err
+	}
+	timestamp := time.Now().Unix()
+	message := fmt.Sprintf(consts.SignatureMessageFormat, method, canonicalURL, timestamp, nonce, signBody)
+	signatureResult, err := c.Signer.Sign(ctx, message)
+	if err != nil {
+		return "", err
+	}
+	authorization := fmt.Sprintf(
+		consts.HeaderAuthorizationFormat, c.getAuthorizationType(),
+		signatureResult.MchID, nonce, timestamp, signatureResult.CertificateSerialNo, signatureResult.Signature,
+	)
+	return authorization, nil
+}
+
+func (c *WechatPayCredentials) getAuthorizationType() string {
+	return "WECHATPAY2-" + c.Signer.Algorithm()
+}

+ 189 - 0
core/auth/credentials/wechat_pay_credential_test.go

@@ -0,0 +1,189 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package credentials
+
+import (
+	"context"
+	"fmt"
+	"github.com/stretchr/testify/assert"
+	"testing"
+	"time"
+
+	"github.com/agiledragon/gomonkey"
+	"github.com/stretchr/testify/require"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+type mockSigner struct {
+	MchID               string
+	CertificateSerialNo string
+}
+
+func (s *mockSigner) Sign(_ context.Context, message string) (*auth.SignatureResult, error) {
+	result := &auth.SignatureResult{
+		MchID:               s.MchID,
+		CertificateSerialNo: s.CertificateSerialNo,
+		Signature:           "Sign:" + message,
+	}
+	return result, nil
+}
+
+func (s *mockSigner) Algorithm() string {
+	return "Mock"
+}
+
+type mockErrorSigner struct {
+}
+
+func (s *mockErrorSigner) Sign(_ context.Context, message string) (*auth.SignatureResult, error) {
+	return nil, fmt.Errorf("mock sign error")
+}
+
+func (s *mockErrorSigner) Algorithm() string {
+	return "ErrorMock"
+}
+
+const (
+	testMchID             = "1234567890"
+	testCertificateSerial = "0123456789ABC"
+	mockNonce             = "A1B2C3D4E5F6G7"
+	mockTimestamp         = 1624523846
+)
+
+func TestWechatPayCredentials_GenerateAuthorizationHeader(t *testing.T) {
+	patches := gomonkey.NewPatches()
+	defer patches.Reset()
+
+	patches.ApplyFunc(
+		utils.GenerateNonce, func() (string, error) {
+			return mockNonce, nil
+		},
+	)
+	patches.ApplyFunc(
+		time.Now, func() time.Time {
+			return time.Unix(mockTimestamp, 0)
+		},
+	)
+
+	signer := mockSigner{
+		MchID:               testMchID,
+		CertificateSerialNo: testCertificateSerial,
+	}
+
+	type args struct {
+		signer auth.Signer
+
+		ctx          context.Context
+		method       string
+		canonicalURL string
+		signBody     string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+		want    string
+	}{
+		{
+			name: "gen success without body",
+			args: args{
+				signer: &signer,
+
+				ctx:          context.Background(),
+				method:       "GET",
+				canonicalURL: "/v3/certificates",
+				signBody:     "",
+			},
+			wantErr: false,
+			want: `WECHATPAY2-Mock mchid="1234567890",nonce_str="A1B2C3D4E5F6G7",timestamp="1624523846",` +
+				`serial_no="0123456789ABC",signature=` +
+				"\"Sign:GET\n/v3/certificates\n1624523846\nA1B2C3D4E5F6G7\n\n\"",
+		},
+		{
+			name: "gen success with body",
+			args: args{
+				signer: &signer,
+
+				ctx:          context.Background(),
+				method:       "POST",
+				canonicalURL: "/v3/certificates",
+				signBody:     "Hello World!\n",
+			},
+			wantErr: false,
+			want: `WECHATPAY2-Mock mchid="1234567890",nonce_str="A1B2C3D4E5F6G7",timestamp="1624523846",` +
+				`serial_no="0123456789ABC",signature=` +
+				"\"Sign:POST\n/v3/certificates\n1624523846\nA1B2C3D4E5F6G7\nHello World!\n\n\"",
+		},
+		{
+			name: "gen error wihout signer",
+			args: args{
+				signer: nil,
+
+				ctx:          context.Background(),
+				method:       "post",
+				canonicalURL: "/v3/certificates",
+				signBody:     "Hello World!\n",
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(
+			tt.name, func(t *testing.T) {
+				credential := WechatPayCredentials{Signer: tt.args.signer}
+
+				authorization, err := credential.GenerateAuthorizationHeader(
+					tt.args.ctx, tt.args.method, tt.args.canonicalURL, tt.args.signBody,
+				)
+				require.Equal(t, tt.wantErr, err != nil)
+				require.Equal(t, tt.want, authorization)
+			},
+		)
+	}
+}
+
+func TestWechatPayCredentials_GenerateAuthorizationHeaderErrorGenerateNonce(t *testing.T) {
+	patches := gomonkey.NewPatches()
+	defer patches.Reset()
+
+	mockGenerateNonceErr := fmt.Errorf("generate nonce error")
+
+	patches.ApplyFunc(
+		utils.GenerateNonce, func() (string, error) {
+			return "", mockGenerateNonceErr
+		},
+	)
+
+	signer := mockSigner{
+		MchID:               testMchID,
+		CertificateSerialNo: testCertificateSerial,
+	}
+	credential := WechatPayCredentials{Signer: &signer}
+
+	authorization, err := credential.GenerateAuthorizationHeader(context.Background(), "GET", "/v3/certificates", "")
+	require.Error(t, err)
+	assert.Empty(t, authorization)
+}
+
+func TestWechatPayCredentials_GenerateAuthorizationHeaderErrorSigner(t *testing.T) {
+	patches := gomonkey.NewPatches()
+	defer patches.Reset()
+
+	patches.ApplyFunc(
+		utils.GenerateNonce, func() (string, error) {
+			return mockNonce, nil
+		},
+	)
+	patches.ApplyFunc(
+		time.Now, func() time.Time {
+			return time.Unix(mockTimestamp, 0)
+		},
+	)
+
+	signer := mockErrorSigner{}
+	credential := WechatPayCredentials{Signer: &signer}
+	authorization, err := credential.GenerateAuthorizationHeader(context.Background(), "GET", "/v3/certificates", "")
+	require.Error(t, err)
+	assert.Empty(t, authorization)
+}

+ 19 - 0
core/auth/signer.go

@@ -0,0 +1,19 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package auth 微信支付 API v3 Go SDK 安全验证相关接口
+package auth
+
+import "context"
+
+// SignatureResult 数字签名结果
+type SignatureResult struct {
+	MchID               string // 商户号
+	CertificateSerialNo string // 签名对应的证书序列号
+	Signature           string // 签名内容
+}
+
+// Signer 数字签名生成器
+type Signer interface {
+	Sign(ctx context.Context, message string) (*SignatureResult, error) // 对信息进行签名
+	Algorithm() string                                                  // 返回使用的签名算法
+}

+ 41 - 0
core/auth/signers/sha256withrsa_signer.go

@@ -0,0 +1,41 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package signers 微信支付 API v3 Go SDK 数字签名生成器
+package signers
+
+import (
+	"context"
+	"crypto/rsa"
+	"fmt"
+	"strings"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+// SHA256WithRSASigner Sha256WithRSA 数字签名生成器
+type SHA256WithRSASigner struct {
+	MchID               string          // 商户号
+	CertificateSerialNo string          // 商户证书序列号
+	PrivateKey          *rsa.PrivateKey // 商户私钥
+}
+
+// Sign 对信息使用 SHA256WithRSA 算法进行签名
+func (s *SHA256WithRSASigner) Sign(_ context.Context, message string) (*auth.SignatureResult, error) {
+	if s.PrivateKey == nil {
+		return nil, fmt.Errorf("you must set privatekey to use SHA256WithRSASigner")
+	}
+	if strings.TrimSpace(s.CertificateSerialNo) == "" {
+		return nil, fmt.Errorf("you must set mch certificate serial no to use SHA256WithRSASigner")
+	}
+	signature, err := utils.SignSHA256WithRSA(message, s.PrivateKey)
+	if err != nil {
+		return nil, err
+	}
+	return &auth.SignatureResult{MchID: s.MchID, CertificateSerialNo: s.CertificateSerialNo, Signature: signature}, nil
+}
+
+// Algorithm 返回使用的签名算法:SHA256-RSA2048
+func (s *SHA256WithRSASigner) Algorithm() string {
+	return "SHA256-RSA2048"
+}

+ 175 - 0
core/auth/signers/sha256withrsa_signer_test.go

@@ -0,0 +1,175 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package signers
+
+import (
+	"context"
+	"crypto/rsa"
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/agiledragon/gomonkey"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+const (
+	testPrivateKeyStr = `-----BEGIN TESTING KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZUJN33V+dSfvd
+fL0Mu+39XrZNXFFMQSy1V15FpncHeV47SmV0TzTqZc7hHB0ddqAdDi8Z5k3TKqb7
+6sOwYr5TcAfuR6PIPaleyE0/0KrljBum2Isa2Nyq7Dgc3ElBQ6YN4l/a+DpvKaz1
+FSKmKrhLNskqokWVSlu4g8OlKlbPXQ9ibII14MZRQrrkTmHYHzfi7GXXM0thAKuR
+0HNvyhTHBh4/lrYM3GaMvmWwkwvsMavnOex6+eioZHBOb1/EIZ/LzC6zuHArPpyW
+3daGaZ1rtQB1vVzTyERAVVFsXXgBHvfFud3w3ShsJYk8JvMwK2RpJ5/gV0QSARcm
+LDRUAlPzAgMBAAECggEBAMc7rDeUaXiWv6bMGbZ3BTXpg1FhdddnWUnYE8HfX/km
+OFI7XtBHXcgYFpcjYz4D5787pcsk7ezPidAj58zqenuclmjKnUmT3pfbI5eCA2v4
+C9HnbYDrmUPK1ZcADtka4D6ScDccpNYNa1g2TFHzkIrEa6H+q7S3O2fqxY/DRVtN
+0JIXalBb8daaqL5QVzSmM2BMVnHy+YITJWIkP2a3pKs9C0W65JGDsnG0wVrHinHF
++cnhFZIbaPEI//DAFMc9NkrWOKVRTEgcCUxCFaHOZVNxDWZD7A2ZfJB2rK6eg//y
+gEiFDR2h6mTaDowMB4YF2n2dsIO4/dCG8vPHI20jn4ECgYEA/ZGu6lEMlO0XZnam
+AZGtiNgLcCfM/C2ZERZE7QTRPZH1WdK92Al9ndldsswFw4baJrJLCmghjF/iG4zi
+hhBvLnOLksnZUfjdumxoHDWXo2QBWbI5QsWIE7AuTiWgWj1I7X4fCXSQf6i+M/y2
+6TogQ7d0ANpZFyOkTNMn/tiJvLECgYEA22XqlamG/yfAGWery5KNH2DGlTIyd6xJ
+WtJ9j3jU99lZ0bCQ5xhiBbU9ImxCi3zgTsoqLWgA/p00HhNFNoUcTl9ofc0G3zwT
+D1y0ZzcnVKxGJdZ6ohW52V0hJStAigtjYAsUgjm7//FH7PiQDBDP1Wa6xSRkDQU/
+aSbQxvEE8+MCgYEA3bb8krW7opyM0XL9RHH0oqsFlVO30Oit5lrqebS0oHl3Zsr2
+ZGgoBlWBsEzk3UqUhTFwm/DhJLTSJ/TQPRkxnhQ5/mewNhS9C7yua7wQkzVmWN+V
+YeUGTvDGDF6qDz12/vJAgSwDDRym8x4NcXD5tTw7mmNRcwIfL22SkysThIECgYAV
+BgccoEoXWS/HP2/u6fQr9ZIR6eV8Ij5FPbZacTG3LlS1Cz5XZra95UgebFFUHHtC
+EY1JHJY7z8SWvTH8r3Su7eWNaIAoFBGffzqqSVazfm6aYZsOvRY6BfqPHT3p/H1h
+Tq6AbBffxrcltgvXnCTORjHPglU0CjSxVs7awW3AEQKBgB5WtaC8VLROM7rkfVIq
++RXqE5vtJfa3e3N7W3RqxKp4zHFAPfr82FK5CX2bppEaxY7SEZVvVInKDc5gKdG/
+jWNRBmvvftZhY59PILHO2X5vO4FXh7suEjy6VIh0gsnK36mmRboYIBGsNuDHjXLe
+BDa+8mDLkWu5nHEhOxy2JJZl
+-----END TESTING KEY-----`
+	testCertificateSerial = `F5765756002FDD77`
+	testExpectedSignature = "BKyAfU4iMCuvXMXS0Wzam3V/cnxZ+JaqigPM5OhljS2iOT95OO6Fsuml2JkFANJU9K6q9bLlDhPXuoVz+pp4hAm6" +
+		"pHU4ld815U4jsKu1RkyaII+1CYBUYC8TK0XtJ8FwUXXz8vZHh58rrAVN1XwNyvD1vfpxrMT4SL536GLwvpUHlCqIMzoZUguLli/K8V29QiOh" +
+		"uH6IEqLNJn8e9b3nwNcQ7be3CzYGpDAKBfDGPCqCv8Rw5zndhlffk2FEA70G4hvMwe51qMN/RAJbknXG23bSlObuTCN7Ndj1aJGH6/L+hdwf" +
+		"LpUtJm4QYVazzW7DFD27EpSQEqA8bX9+8m1rLg=="
+	testMessage = "source"
+	testMchID   = "1234567890"
+)
+
+func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") }
+
+func TestSha256WithRSASigner_Sign(t *testing.T) {
+	privateKey, err := utils.LoadPrivateKey(testingKey(testPrivateKeyStr))
+	require.NoError(t, err)
+
+	type args struct {
+		mchID      string
+		certSerial string
+		privateKey *rsa.PrivateKey
+
+		message string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    *auth.SignatureResult
+		wantErr bool
+		mock    func()
+	}{
+		{
+			name: "Sha256WithRSASigner_Sign success",
+			args: args{
+				mchID:      testMchID,
+				certSerial: testCertificateSerial,
+				privateKey: privateKey,
+
+				message: testMessage,
+			},
+			want: &auth.SignatureResult{
+				MchID:               testMchID,
+				CertificateSerialNo: testCertificateSerial,
+				Signature:           testExpectedSignature,
+			},
+			wantErr: false,
+		},
+		{
+			name: "Sha256WithRSASigner_Sign err when unset certificateSerialNo",
+			args: args{
+				mchID:      testMchID,
+				certSerial: "    ",
+				privateKey: privateKey,
+
+				message: testMessage,
+			},
+			wantErr: true,
+		},
+		{
+			name: "Sha256WithRSASigner_Sign err when unset privateKey",
+			args: args{
+				mchID:      testMchID,
+				certSerial: testCertificateSerial,
+				privateKey: nil,
+
+				message: testMessage,
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(
+			tt.name, func(t *testing.T) {
+				s := &SHA256WithRSASigner{
+					MchID:               tt.args.mchID,
+					CertificateSerialNo: tt.args.certSerial,
+					PrivateKey:          tt.args.privateKey,
+				}
+				got, err := s.Sign(context.Background(), tt.args.message)
+				require.Equal(t, tt.wantErr, err != nil)
+
+				if err == nil {
+					require.NotNil(t, got)
+
+					assert.Equal(t, tt.want.MchID, got.MchID)
+					assert.Equal(t, tt.want.CertificateSerialNo, got.CertificateSerialNo)
+					assert.Equal(t, tt.want.Signature, got.Signature)
+				}
+			},
+		)
+	}
+}
+
+func TestSha256WithRSASigner_SignErrorSignSHA256WithRSA(t *testing.T) {
+	patches := gomonkey.NewPatches()
+	defer patches.Reset()
+
+	patches.ApplyFunc(
+		utils.SignSHA256WithRSA, func(source string, privateKey *rsa.PrivateKey) (signature string, err error) {
+			return "", fmt.Errorf("sign error")
+		},
+	)
+
+	privateKey, err := utils.LoadPrivateKey(testingKey(testPrivateKeyStr))
+	require.NoError(t, err)
+
+	s := &SHA256WithRSASigner{
+		MchID:               testMchID,
+		CertificateSerialNo: testCertificateSerial,
+		PrivateKey:          privateKey,
+	}
+
+	result, err := s.Sign(context.Background(), testMessage)
+	require.Error(t, err)
+	assert.Nil(t, result)
+}
+
+func TestWechatPayVerifier_Algorithm(t *testing.T) {
+	privateKey, err := utils.LoadPrivateKey(testingKey(testPrivateKeyStr))
+	require.NoError(t, err)
+
+	s := &SHA256WithRSASigner{
+		MchID:               testMchID,
+		CertificateSerialNo: testCertificateSerial,
+		PrivateKey:          privateKey,
+	}
+
+	assert.Equal(t, "SHA256-RSA2048", s.Algorithm())
+}

+ 15 - 0
core/auth/validator.go

@@ -0,0 +1,15 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package auth 微信支付 API v3 Go SDK 安全验证相关接口
+package auth
+
+import (
+	"context"
+	"net/http"
+)
+
+// Validator 应答报文验证器
+type Validator interface {
+	Validate(ctx context.Context, response *http.Response) error    // 对 HTTP 应答报文进行验证
+	GetAcceptSerial(ctx context.Context) (serial string, err error) // 客户端可以处理的证书或者公钥序列号
+}

+ 24 - 0
core/auth/validators/null_validator.go

@@ -0,0 +1,24 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package validators 微信支付 API v3 Go SDK 应答报文签名验证器
+package validators
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+)
+
+// NullValidator 空验证器,不对报文进行验证,对任意报文均不会返回错误,
+// 在不需要对报文签名进行验证的情况(如微信支付账单文件下载)下使用
+type NullValidator struct {
+}
+
+// Validate 跳过报文签名验证
+func (v *NullValidator) Validate(context.Context, *http.Response) error {
+	return nil
+}
+
+func (v *NullValidator) GetAcceptSerial(ctx context.Context) (serial string, err error) {
+	return "", fmt.Errorf("NullValidator has no serial")
+}

+ 352 - 0
core/auth/validators/validator_test.go

@@ -0,0 +1,352 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package validators
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/agiledragon/gomonkey"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
+)
+
+type mockVerifier struct {
+}
+
+func (v *mockVerifier) GetSerial(ctx context.Context) (serial string, err error) {
+	return "SERIAL1234567890", nil
+}
+
+func (v *mockVerifier) Verify(ctx context.Context, serialNumber string, message string, signature string) error {
+	if "["+serialNumber+"-"+message+"]" == signature {
+		return nil
+	}
+
+	return fmt.Errorf("verification failed")
+}
+
+func TestWechatPayResponseValidator_Validate_Success(t *testing.T) {
+	mockTimestamp := time.Now().Unix()
+	mockTimestampStr := fmt.Sprintf("%d", mockTimestamp)
+
+	validator := NewWechatPayResponseValidator(&mockVerifier{})
+
+	type args struct {
+		ctx      context.Context
+		response *http.Response
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "response validate success",
+			args: args{
+				ctx: context.Background(),
+				response: &http.Response{
+					Header: http.Header{
+						consts.WechatPaySignature: {
+							"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567890\nBODY\n]",
+						},
+						consts.WechatPaySerial:    {"SERIAL1234567890"},
+						consts.WechatPayTimestamp: {mockTimestampStr},
+						consts.WechatPayNonce:     {"NONCE1234567890"},
+						consts.RequestID:          {"any-request-id"},
+					},
+					Body: ioutil.NopCloser(bytes.NewBuffer([]byte("BODY"))),
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "response validate success without body",
+			args: args{
+				ctx: context.Background(),
+				response: &http.Response{
+					Header: http.Header{
+						consts.WechatPaySignature: {
+							"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567890\n\n]",
+						},
+						consts.WechatPaySerial:    {"SERIAL1234567890"},
+						consts.WechatPayTimestamp: {mockTimestampStr},
+						consts.WechatPayNonce:     {"NONCE1234567890"},
+						consts.RequestID:          {"any-request-id"},
+					},
+					Body: ioutil.NopCloser(bytes.NewBuffer([]byte(""))),
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "response validate success without RequestID",
+			args: args{
+				ctx: context.Background(),
+				response: &http.Response{
+					Header: http.Header{
+						consts.WechatPaySignature: {
+							"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567890\n\n]",
+						},
+						consts.WechatPaySerial:    {"SERIAL1234567890"},
+						consts.WechatPayTimestamp: {mockTimestampStr},
+						consts.WechatPayNonce:     {"NONCE1234567890"},
+					},
+					Body: ioutil.NopCloser(bytes.NewBuffer([]byte(""))),
+				},
+			},
+			wantErr: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(
+			tt.name, func(t *testing.T) {
+				if err := validator.Validate(tt.args.ctx, tt.args.response); (err != nil) != tt.wantErr {
+					t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
+				}
+			},
+		)
+	}
+}
+
+func TestWechatPayResponseValidator_Validate_Failure(t *testing.T) {
+	mockTimestamp := time.Now().Unix()
+	mockTimestampStr := fmt.Sprintf("%d", mockTimestamp)
+
+	validator := NewWechatPayResponseValidator(&mockVerifier{})
+
+	type args struct {
+		ctx      context.Context
+		response *http.Response
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "response validate error with error signature",
+			args: args{
+				ctx: context.Background(),
+				response: &http.Response{
+					Header: http.Header{
+						consts.WechatPaySignature: {"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567\n\n]"},
+						consts.WechatPaySerial:    {"SERIAL1234567890"},
+						consts.WechatPayTimestamp: {mockTimestampStr},
+						consts.WechatPayNonce:     {"NONCE1234567890"},
+						consts.RequestID:          {"any-request-id"},
+					},
+					Body: ioutil.NopCloser(bytes.NewBuffer([]byte(""))),
+				},
+			},
+			wantErr: true,
+		},
+		{
+			name: "response validate error missing signature",
+			args: args{
+				ctx: context.Background(),
+				response: &http.Response{
+					Header: http.Header{
+						consts.WechatPaySerial:    {"SERIAL1234567890"},
+						consts.WechatPayTimestamp: {mockTimestampStr},
+						consts.WechatPayNonce:     {"NONCE1234567890"},
+						consts.RequestID:          {"any-request-id"},
+					},
+					Body: ioutil.NopCloser(bytes.NewBuffer([]byte(""))),
+				},
+			},
+			wantErr: true,
+		},
+		{
+			name: "response validate error missing serial",
+			args: args{
+				ctx: context.Background(),
+				response: &http.Response{
+					Header: http.Header{
+						consts.WechatPaySignature: {"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567890\n]"},
+						consts.WechatPayTimestamp: {mockTimestampStr},
+						consts.WechatPayNonce:     {"NONCE1234567890"},
+						consts.RequestID:          {"any-request-id"},
+					},
+					Body: ioutil.NopCloser(bytes.NewBuffer([]byte(""))),
+				},
+			},
+			wantErr: true,
+		},
+		{
+			name: "response validate error missing timestamp",
+			args: args{
+				ctx: context.Background(),
+				response: &http.Response{
+					Header: http.Header{
+						consts.WechatPaySignature: {"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567890\n]"},
+						consts.WechatPaySerial:    {"SERIAL1234567890"},
+						consts.WechatPayNonce:     {"NONCE1234567890"},
+						consts.RequestID:          {"any-request-id"},
+					},
+					Body: ioutil.NopCloser(bytes.NewBuffer([]byte(""))),
+				},
+			},
+			wantErr: true,
+		},
+		{
+			name: "response validate error invalid timestamp",
+			args: args{
+				ctx: context.Background(),
+				response: &http.Response{
+					Header: http.Header{
+						consts.WechatPaySignature: {"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567890\n]"},
+						consts.WechatPaySerial:    {"SERIAL1234567890"},
+						consts.WechatPayTimestamp: {"invalid timestamp"},
+						consts.WechatPayNonce:     {"NONCE1234567890"},
+						consts.RequestID:          {"any-request-id"},
+					},
+					Body: ioutil.NopCloser(bytes.NewBuffer([]byte(""))),
+				},
+			},
+			wantErr: true,
+		},
+		{
+			name: "response validate error missing nonce",
+			args: args{
+				ctx: context.Background(),
+				response: &http.Response{
+					Header: http.Header{
+						consts.WechatPaySignature: {"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567890\n]"},
+						consts.WechatPaySerial:    {"SERIAL1234567890"},
+						consts.WechatPayTimestamp: {mockTimestampStr},
+						consts.RequestID:          {"any-request-id"},
+					},
+					Body: ioutil.NopCloser(bytes.NewBuffer([]byte(""))),
+				},
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(
+			tt.name, func(t *testing.T) {
+				if err := validator.Validate(tt.args.ctx, tt.args.response); (err != nil) != tt.wantErr {
+					t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
+				}
+			},
+		)
+	}
+}
+
+func TestWechatPayResponseValidator_WithoutVerifierShouldFail(t *testing.T) {
+	mockTimestamp := time.Now().Unix()
+	mockTimestampStr := fmt.Sprintf("%d", mockTimestamp)
+
+	invalidValidator := NewWechatPayResponseValidator(nil)
+
+	response := &http.Response{
+		Header: http.Header{
+			consts.WechatPaySignature: {
+				"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567890\nBODY\n]",
+			},
+			consts.WechatPaySerial:    {"SERIAL1234567890"},
+			consts.WechatPayTimestamp: {mockTimestampStr},
+			consts.WechatPayNonce:     {"NONCE1234567890"},
+			consts.RequestID:          {"any-request-id"},
+		},
+		Body: ioutil.NopCloser(bytes.NewBuffer([]byte("BODY"))),
+	}
+
+	err := invalidValidator.Validate(context.Background(), response)
+	assert.Error(t, err)
+}
+
+func TestWechatPayResponseValidator_ValidateReadBodyErrorShouldFail(t *testing.T) {
+	patches := gomonkey.NewPatches()
+	defer patches.Reset()
+
+	patches.ApplyFunc(ioutil.ReadAll, func(r io.Reader) ([]byte, error) {
+		return nil, fmt.Errorf("read error")
+	})
+
+	mockTimestamp := time.Now().Unix()
+	mockTimestampStr := fmt.Sprintf("%d", mockTimestamp)
+
+	validator := NewWechatPayResponseValidator(&mockVerifier{})
+
+	response := &http.Response{
+		Header: http.Header{
+			consts.WechatPaySignature: {
+				"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567890\nBODY\n]",
+			},
+			consts.WechatPaySerial:    {"SERIAL1234567890"},
+			consts.WechatPayTimestamp: {mockTimestampStr},
+			consts.WechatPayNonce:     {"NONCE1234567890"},
+			consts.RequestID:          {"any-request-id"},
+		},
+		Body: ioutil.NopCloser(bytes.NewBuffer([]byte("BODY"))),
+	}
+
+	err := validator.Validate(context.Background(), response)
+	assert.Error(t, err)
+}
+
+func TestNullValidator_Validate(t *testing.T) {
+	nullValidator := NullValidator{}
+
+	assert.NoError(t, nullValidator.Validate(context.Background(), &http.Response{}))
+	assert.NoError(t, nullValidator.Validate(context.Background(), nil))
+}
+
+func TestWechatPayNotifyValidator_Validate(t *testing.T) {
+	mockTimestamp := time.Now().Unix()
+	mockTimestampStr := fmt.Sprintf("%d", mockTimestamp)
+
+	validator := NewWechatPayNotifyValidator(&mockVerifier{})
+
+	request := httptest.NewRequest("Post", "http://127.0.0.1", ioutil.NopCloser(bytes.NewBuffer([]byte("BODY"))))
+	request.Header = http.Header{
+		consts.WechatPaySignature: {
+			"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567890\nBODY\n]",
+		},
+		consts.WechatPaySerial:    {"SERIAL1234567890"},
+		consts.WechatPayTimestamp: {mockTimestampStr},
+		consts.WechatPayNonce:     {"NONCE1234567890"},
+		consts.RequestID:          {"any-request-id"},
+	}
+
+	err := validator.Validate(context.Background(), request)
+	assert.NoError(t, err)
+}
+
+func TestWechatPayNotifyValidator_ValidateReadBodyError(t *testing.T) {
+	patches := gomonkey.NewPatches()
+	defer patches.Reset()
+
+	patches.ApplyFunc(ioutil.ReadAll, func(r io.Reader) ([]byte, error) {
+		return nil, fmt.Errorf("read error")
+	})
+
+	mockTimestamp := time.Now().Unix()
+	mockTimestampStr := fmt.Sprintf("%d", mockTimestamp)
+
+	validator := NewWechatPayNotifyValidator(&mockVerifier{})
+
+	request := httptest.NewRequest("Post", "http://127.0.0.1", ioutil.NopCloser(bytes.NewBuffer([]byte("BODY"))))
+	request.Header = http.Header{
+		consts.WechatPaySignature: {
+			"[SERIAL1234567890-" + mockTimestampStr + "\nNONCE1234567890\nBODY\n]",
+		},
+		consts.WechatPaySerial:    {"SERIAL1234567890"},
+		consts.WechatPayTimestamp: {mockTimestampStr},
+		consts.WechatPayNonce:     {"NONCE1234567890"},
+		consts.RequestID:          {"any-request-id"},
+	}
+
+	err := validator.Validate(context.Background(), request)
+	assert.Error(t, err)
+}

+ 38 - 0
core/auth/validators/wechat_pay_notify_validator.go

@@ -0,0 +1,38 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package validators
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+)
+
+// WechatPayNotifyValidator 微信支付 API v3 通知请求报文验证器
+type WechatPayNotifyValidator struct {
+	wechatPayValidator
+}
+
+// Validate 对接收到的微信支付 API v3 通知请求报文进行验证
+func (v *WechatPayNotifyValidator) Validate(ctx context.Context, request *http.Request) error {
+	body, err := ioutil.ReadAll(request.Body)
+	if err != nil {
+		return fmt.Errorf("read request body err: %v", err)
+	}
+
+	_ = request.Body.Close()
+	request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
+
+	return v.validateHTTPMessage(ctx, request.Header, body)
+}
+
+// NewWechatPayNotifyValidator 使用 auth.Verifier 初始化一个 WechatPayNotifyValidator
+func NewWechatPayNotifyValidator(verifier auth.Verifier) *WechatPayNotifyValidator {
+	return &WechatPayNotifyValidator{
+		wechatPayValidator{verifier: verifier},
+	}
+}

+ 42 - 0
core/auth/validators/wechat_pay_response_validator.go

@@ -0,0 +1,42 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package validators 微信支付 API v3 Go SDK 应答报文验证器
+package validators
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+)
+
+// WechatPayResponseValidator 微信支付 API v3 默认应答报文验证器
+type WechatPayResponseValidator struct {
+	wechatPayValidator
+}
+
+// Validate 使用验证器对微信支付应答报文进行验证
+func (v *WechatPayResponseValidator) Validate(ctx context.Context, response *http.Response) error {
+	body, err := ioutil.ReadAll(response.Body)
+	if err != nil {
+		return fmt.Errorf("read response body err:[%s]", err.Error())
+	}
+	response.Body = ioutil.NopCloser(bytes.NewBuffer(body))
+
+	return v.validateHTTPMessage(ctx, response.Header, body)
+}
+
+// GetAcceptSerial 客户端可以处理的证书或者公钥序列号
+func (v *WechatPayResponseValidator) GetAcceptSerial(ctx context.Context) (string, error) {
+	return v.getAcceptSerial(ctx)
+}
+
+// NewWechatPayResponseValidator 使用 auth.Verifier 初始化一个 WechatPayResponseValidator
+func NewWechatPayResponseValidator(verifier auth.Verifier) *WechatPayResponseValidator {
+	return &WechatPayResponseValidator{
+		wechatPayValidator{verifier: verifier},
+	}
+}

+ 129 - 0
core/auth/validators/wechat_pay_validator.go

@@ -0,0 +1,129 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package validators
+
+import (
+	"context"
+	"fmt"
+	"math"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
+)
+
+type wechatPayValidator struct {
+	verifier auth.Verifier
+}
+
+type wechatPayHeader struct {
+	RequestID string
+	Serial    string
+	Signature string
+	Nonce     string
+	Timestamp int64
+}
+
+func (v *wechatPayValidator) validateHTTPMessage(ctx context.Context, header http.Header, body []byte) error {
+	if v.verifier == nil {
+		return fmt.Errorf("you must init Validator with auth.Verifier")
+	}
+
+	headerArgs, err := getWechatPayHeader(ctx, header)
+	if err != nil {
+		return err
+	}
+
+	if err := checkWechatPayHeader(ctx, headerArgs); err != nil {
+		return err
+	}
+
+	message := buildMessage(ctx, headerArgs, body)
+
+	if err := v.verifier.Verify(ctx, headerArgs.Serial, message, headerArgs.Signature); err != nil {
+		return fmt.Errorf(
+			"validate verify fail serial=[%s] request-id=[%s] err=%w",
+			headerArgs.Serial, headerArgs.RequestID, err,
+		)
+	}
+	return nil
+}
+
+func (v *wechatPayValidator) getAcceptSerial(ctx context.Context) (string, error) {
+	return v.verifier.GetSerial(ctx)
+}
+
+// getWechatPayHeader 从 http.Header 中获取 wechatPayHeader 信息
+func getWechatPayHeader(ctx context.Context, header http.Header) (wechatPayHeader, error) {
+	_ = ctx // Suppressing warnings
+
+	requestID := strings.TrimSpace(header.Get(consts.RequestID))
+
+	getHeaderString := func(key string) (string, error) {
+		val := strings.TrimSpace(header.Get(key))
+		if val == "" {
+			return "", fmt.Errorf("key `%s` is empty in header, request-id=[%s]", key, requestID)
+		}
+		return val, nil
+	}
+
+	getHeaderInt64 := func(key string) (int64, error) {
+		val, err := getHeaderString(key)
+		if err != nil {
+			return 0, nil
+		}
+		ret, err := strconv.ParseInt(val, 10, 64)
+		if err != nil {
+			return 0, fmt.Errorf("invalid `%s` in header, request-id=[%s], err:%w", key, requestID, err)
+		}
+		return ret, nil
+	}
+
+	ret := wechatPayHeader{
+		RequestID: requestID,
+	}
+	var err error
+
+	if ret.Serial, err = getHeaderString(consts.WechatPaySerial); err != nil {
+		return ret, err
+	}
+
+	if ret.Signature, err = getHeaderString(consts.WechatPaySignature); err != nil {
+		return ret, err
+	}
+
+	if ret.Timestamp, err = getHeaderInt64(consts.WechatPayTimestamp); err != nil {
+		return ret, err
+	}
+
+	if ret.Nonce, err = getHeaderString(consts.WechatPayNonce); err != nil {
+		return ret, err
+	}
+
+	return ret, nil
+}
+
+// checkWechatPayHeader 对 wechatPayHeader 内容进行检查,看是否符合要求
+//
+// 检查项:
+//   - Timestamp 与当前时间之差不得超过 FiveMinute;
+func checkWechatPayHeader(ctx context.Context, args wechatPayHeader) error {
+	// Suppressing warnings
+	_ = ctx
+
+	if math.Abs(float64(time.Now().Unix()-args.Timestamp)) >= consts.FiveMinute {
+		return fmt.Errorf("timestamp=[%d] expires, request-id=[%s]", args.Timestamp, args.RequestID)
+	}
+	return nil
+}
+
+// buildMessage 根据微信支付签名格式构造验签原文
+func buildMessage(ctx context.Context, headerArgs wechatPayHeader, body []byte) string {
+	// Suppressing warnings
+	_ = ctx
+
+	return fmt.Sprintf("%d\n%s\n%s\n", headerArgs.Timestamp, headerArgs.Nonce, string(body))
+}

+ 12 - 0
core/auth/verifier.go

@@ -0,0 +1,12 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package auth 微信支付 API v3 Go SDK 安全验证相关接口
+package auth
+
+import "context"
+
+// Verifier 数字签名验证器
+type Verifier interface {
+	Verify(ctx context.Context, serial, message, signature string) error // 对签名信息进行验证
+	GetSerial(ctx context.Context) (serial string, err error)            // 获取可验签的平台证书或公钥序列号
+}

+ 37 - 0
core/auth/verifiers/sha256withrsa_combined_verifier.go

@@ -0,0 +1,37 @@
+package verifiers
+
+import (
+	"context"
+	"crypto/rsa"
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+)
+
+// SHA256WithRSACombinedVerifier 数字签名验证器,组合了公钥和平台证书
+type SHA256WithRSACombinedVerifier struct {
+	publicKeyVerifier SHA256WithRSAPubkeyVerifier
+	certVerifier      SHA256WithRSAVerifier
+}
+
+// Verify 验证签名,如果序列号和公钥一致则使用公钥验签,否则使用平台证书验签
+func (v *SHA256WithRSACombinedVerifier) Verify(ctx context.Context, serialNumber, message, signature string) error {
+	if serialNumber == v.publicKeyVerifier.keyID {
+		return v.publicKeyVerifier.Verify(ctx, serialNumber, message, signature)
+	}
+	return v.certVerifier.Verify(ctx, serialNumber, message, signature)
+}
+
+// GetSerial 获取可验签的公钥序列号。该验签器只用在回调,所以获取序列号时返回错误
+func (v *SHA256WithRSACombinedVerifier) GetSerial(ctx context.Context) (string, error) {
+	return v.publicKeyVerifier.keyID, nil
+}
+
+// NewSHA256WithRSACombinedVerifier 用公钥和平台证书初始化验证器
+func NewSHA256WithRSACombinedVerifier(
+	getter core.CertificateGetter,
+	keyID string,
+	publicKey rsa.PublicKey) *SHA256WithRSACombinedVerifier {
+	return &SHA256WithRSACombinedVerifier{
+		*NewSHA256WithRSAPubkeyVerifier(keyID, publicKey),
+		*NewSHA256WithRSAVerifier(getter),
+	}
+}

+ 50 - 0
core/auth/verifiers/sha256withrsa_pubkey_verifier.go

@@ -0,0 +1,50 @@
+// Copyright 2024 Tencent Inc. All rights reserved.
+
+// Package verifiers 微信支付 API v3 Go SDK 数字签名验证器
+package verifiers
+
+import (
+	"context"
+	"crypto"
+	"crypto/rsa"
+	"crypto/sha256"
+	"encoding/base64"
+	"fmt"
+)
+
+// SHA256WithRSAPubkeyVerifier 数字签名验证器,使用微信支付提供的公钥验证签名
+type SHA256WithRSAPubkeyVerifier struct {
+	keyID     string
+	publicKey rsa.PublicKey
+}
+
+// Verify 使用微信支付提供的公钥验证签名
+func (v *SHA256WithRSAPubkeyVerifier) Verify(ctx context.Context, serialNumber, message, signature string) error {
+	if ctx == nil {
+		return fmt.Errorf("verify failed: context is nil")
+	}
+	if v.keyID != serialNumber {
+		return fmt.Errorf("verify failed: key-id[%s] does not match serial number[%s]", v.keyID, serialNumber)
+	}
+
+	sigBytes, err := base64.StdEncoding.DecodeString(signature)
+	if err != nil {
+		return fmt.Errorf("verify failed: signature is not base64 encoded")
+	}
+	hashed := sha256.Sum256([]byte(message))
+	err = rsa.VerifyPKCS1v15(&v.publicKey, crypto.SHA256, hashed[:], sigBytes)
+	if err != nil {
+		return fmt.Errorf("verify signature with public key error:%s", err.Error())
+	}
+	return nil
+}
+
+// GetSerial 获取可验签的公钥序列号
+func (v *SHA256WithRSAPubkeyVerifier) GetSerial(ctx context.Context) (string, error) {
+	return v.keyID, nil
+}
+
+// NewSHA256WithRSAPubkeyVerifier 使用 rsa.PublicKey 初始化验签器
+func NewSHA256WithRSAPubkeyVerifier(keyID string, publicKey rsa.PublicKey) *SHA256WithRSAPubkeyVerifier {
+	return &SHA256WithRSAPubkeyVerifier{keyID: keyID, publicKey: publicKey}
+}

+ 154 - 0
core/auth/verifiers/sha256withrsa_pubkey_verifier_test.go

@@ -0,0 +1,154 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package verifiers
+
+import (
+	"context"
+	"crypto/rsa"
+	"testing"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+const (
+	testPubKeyID = "F5765756002FDD77"
+	testPubKey   = `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2VCTd91fnUn73Xy9DLvt
+/V62TVxRTEEstVdeRaZ3B3leO0pldE806mXO4RwdHXagHQ4vGeZN0yqm++rDsGK+
+U3AH7kejyD2pXshNP9Cq5YwbptiLGtjcquw4HNxJQUOmDeJf2vg6byms9RUipiq4
+SzbJKqJFlUpbuIPDpSpWz10PYmyCNeDGUUK65E5h2B834uxl1zNLYQCrkdBzb8oU
+xwYeP5a2DNxmjL5lsJML7DGr5znsevnoqGRwTm9fxCGfy8wus7hwKz6clt3Whmmd
+a7UAdb1c08hEQFVRbF14AR73xbnd8N0obCWJPCbzMCtkaSef4FdEEgEXJiw0VAJT
+8wIDAQAB
+-----END PUBLIC KEY-----`
+	// testExpectedSignature = "BKyAfU4iMCuvXMXS0Wzam3V/cnxZ+JaqigPM5OhljS2iOT95OO6Fsuml2JkFANJU9" +
+	// 	"K6q9bLlDhPXuoVz+pp4hAm6pHU4ld815U4jsKu1RkyaII+1CYBUYC8TK0XtJ8FwUXXz8vZHh58rrAVN1XwNyv" +
+	// 	"D1vfpxrMT4SL536GLwvpUHlCqIMzoZUguLli/K8V29QiOhuH6IEqLNJn8e9b3nwNcQ7be3CzYGpDAKBfDGPCq" +
+	// 	"Cv8Rw5zndhlffk2FEA70G4hvMwe51qMN/RAJbknXG23bSlObuTCN7Ndj1aJGH6/L+hdwfLpUtJm4QYVazzW7D" +
+	// 	"FD27EpSQEqA8bX9+8m1rLg=="
+)
+
+var (
+	pubKey *rsa.PublicKey
+)
+
+func init() {
+	var err error
+	pubKey, err = utils.LoadPublicKey(testPubKey)
+	if err != nil {
+		panic(err)
+	}
+}
+
+func TestWechatPayPubKeyVerifier(t *testing.T) {
+	type args struct {
+		ctx          context.Context
+		serialNumber string
+		message      string
+		signature    string
+	}
+	tests := []struct {
+		name    string
+		fields  *rsa.PublicKey
+		args    args
+		wantErr bool
+	}{
+		{
+			name:   "verify success",
+			fields: pubKey,
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testPubKeyID,
+				signature:    testExpectedSignature,
+				message:      "source",
+			},
+			wantErr: false,
+		},
+		{
+			name:   "verify failed",
+			fields: pubKey,
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testPubKeyID,
+				signature:    testExpectedSignature,
+				message:      "wrong source",
+			},
+			wantErr: true,
+		},
+		{
+			name:   "verify failed with null context",
+			fields: pubKey,
+			args: args{
+				ctx:          nil,
+				serialNumber: testWechatPayVerifierPlatformSerialNumber,
+				signature:    testExpectedSignature,
+				message:      "source",
+			},
+			wantErr: true,
+		},
+		{
+			name:   "verify failed with empty keyId",
+			fields: pubKey,
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: "",
+				signature:    testExpectedSignature,
+				message:      "source",
+			},
+			wantErr: true,
+		},
+		{
+			name:   "verify failed with empty message",
+			fields: pubKey,
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testPubKeyID,
+				signature:    testExpectedSignature,
+				message:      "",
+			},
+			wantErr: true,
+		},
+		{
+			name:   "verify failed with empty signature",
+			fields: pubKey,
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testPubKeyID,
+				signature:    "",
+				message:      "source",
+			},
+			wantErr: true,
+		},
+		{
+			name:   "verify failed with non-base64 signature",
+			fields: pubKey,
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testPubKeyID,
+				signature:    "invalid base64 signature",
+				message:      "source",
+			},
+			wantErr: true,
+		},
+		{
+			name:   "verify failed with no corresponding pubkey",
+			fields: pubKey,
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: "invalid serial number",
+				signature:    testExpectedSignature,
+				message:      "source",
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var verifier = NewSHA256WithRSAPubkeyVerifier(testPubKeyID, *tt.fields)
+			if err := verifier.Verify(tt.args.ctx, tt.args.serialNumber, tt.args.message,
+				tt.args.signature); (err != nil) != tt.wantErr {
+				t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}

+ 73 - 0
core/auth/verifiers/sha256withrsa_verifier.go

@@ -0,0 +1,73 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package verifiers 微信支付 API v3 Go SDK 数字签名验证器
+package verifiers
+
+import (
+	"context"
+	"crypto"
+	"crypto/rsa"
+	"crypto/sha256"
+	"encoding/base64"
+	"fmt"
+	"strings"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+)
+
+// SHA256WithRSAVerifier SHA256WithRSA 数字签名验证器
+type SHA256WithRSAVerifier struct {
+	// Certificates 微信支付平台证书Map,key: 平台证书序列号, value: 微信支付平台证书
+	certGetter core.CertificateGetter
+}
+
+// Verify 对数字签名信息进行验证
+func (verifier *SHA256WithRSAVerifier) Verify(ctx context.Context, serialNumber, message, signature string) error {
+	err := checkParameter(ctx, serialNumber, message, signature)
+	if err != nil {
+		return err
+	}
+	if verifier.certGetter == nil {
+		return fmt.Errorf("verifier has no validator")
+	}
+	sigBytes, err := base64.StdEncoding.DecodeString(signature)
+	if err != nil {
+		return fmt.Errorf("verify failed: signature not base64 encoded")
+	}
+	certificate, ok := verifier.certGetter.Get(ctx, serialNumber)
+	if !ok {
+		return fmt.Errorf("certificate[%s] not found in verifier", serialNumber)
+	}
+	hashed := sha256.Sum256([]byte(message))
+	err = rsa.VerifyPKCS1v15(certificate.PublicKey.(*rsa.PublicKey), crypto.SHA256, hashed[:], sigBytes)
+	if err != nil {
+		return fmt.Errorf("verify signature with public key err:%s", err.Error())
+	}
+	return nil
+}
+
+// GetSerial 获取可验签的平台证书序列号
+func (verifier *SHA256WithRSAVerifier) GetSerial(ctx context.Context) (string, error) {
+	return verifier.certGetter.GetNewestSerial(ctx), nil
+}
+
+func checkParameter(ctx context.Context, serialNumber, message, signature string) error {
+	if ctx == nil {
+		return fmt.Errorf("context is nil, verifier need input context.Context")
+	}
+	if strings.TrimSpace(serialNumber) == "" {
+		return fmt.Errorf("serialNumber is empty, verifier need input serialNumber")
+	}
+	if strings.TrimSpace(message) == "" {
+		return fmt.Errorf("message is empty, verifier need input message")
+	}
+	if strings.TrimSpace(signature) == "" {
+		return fmt.Errorf("signature is empty, verifier need input signature")
+	}
+	return nil
+}
+
+// NewSHA256WithRSAVerifier 使用 core.CertificateGetter 初始化 SHA256WithRSAVerifier
+func NewSHA256WithRSAVerifier(getter core.CertificateGetter) *SHA256WithRSAVerifier {
+	return &SHA256WithRSAVerifier{certGetter: getter}
+}

+ 198 - 0
core/auth/verifiers/sha256withrsa_verifier_test.go

@@ -0,0 +1,198 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package verifiers
+
+import (
+	"context"
+	"crypto/x509"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"testing"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+const (
+	testWechatPayVerifierPlatformSerialNumber = "F5765756002FDD77"
+	testWechatPayVerifierPlatformCertificate  = `-----BEGIN CERTIFICATE-----
+MIIDVzCCAj+gAwIBAgIJAPV2V1YAL913MA0GCSqGSIb3DQEBCwUAMEIxCzAJBgNV
+BAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQg
+Q29tcGFueSBMdGQwHhcNMjEwNDI3MDg0MDMyWhcNMzEwNDI1MDg0MDMyWjBCMQsw
+CQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZh
+dWx0IENvbXBhbnkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
+2VCTd91fnUn73Xy9DLvt/V62TVxRTEEstVdeRaZ3B3leO0pldE806mXO4RwdHXag
+HQ4vGeZN0yqm++rDsGK+U3AH7kejyD2pXshNP9Cq5YwbptiLGtjcquw4HNxJQUOm
+DeJf2vg6byms9RUipiq4SzbJKqJFlUpbuIPDpSpWz10PYmyCNeDGUUK65E5h2B83
+4uxl1zNLYQCrkdBzb8oUxwYeP5a2DNxmjL5lsJML7DGr5znsevnoqGRwTm9fxCGf
+y8wus7hwKz6clt3Whmmda7UAdb1c08hEQFVRbF14AR73xbnd8N0obCWJPCbzMCtk
+aSef4FdEEgEXJiw0VAJT8wIDAQABo1AwTjAdBgNVHQ4EFgQUT1c7nd/SUO76HSoZ
+umNUJv1R5PwwHwYDVR0jBBgwFoAUT1c7nd/SUO76HSoZumNUJv1R5PwwDAYDVR0T
+BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAM+tslqBxYwqL9fdvGG6hfy69sjfX
+UhBtBLWYugKKQCOWWLeq5dDWm3i5Cx2Rgiy9uc7RfmJNxQfIKlcoCNP85BjDoG1B
+YnVc6znlcrT9uHgseha3987WwZsFAQbcy8TLUYHzVB8gmDgq8O08xdIe0eczatI8
+t3Rg8WXO6Gs66JJ4JR+rD01o3FiSOQCRWhn19NSyDydsgPlOR2t9B9L+MkJwlsMG
+Krn85TnwL3qcInzRnU8X86faXXJrI0IJi44tECKw8ftngCl6vyNwNNKPDwdkcuuV
+8y3iBixO5IuKxEKEp2wGPV/4W1AXO73Z3Gb7z/1oxdgeO0hVqz1hBasTCQ==
+-----END CERTIFICATE-----`
+	testExpectedSignature = "BKyAfU4iMCuvXMXS0Wzam3V/cnxZ+JaqigPM5OhljS2iOT95OO6Fsuml2JkFANJU9" +
+		"K6q9bLlDhPXuoVz+pp4hAm6pHU4ld815U4jsKu1RkyaII+1CYBUYC8TK0XtJ8FwUXXz8vZHh58rrAVN1XwNyv" +
+		"D1vfpxrMT4SL536GLwvpUHlCqIMzoZUguLli/K8V29QiOhuH6IEqLNJn8e9b3nwNcQ7be3CzYGpDAKBfDGPCq" +
+		"Cv8Rw5zndhlffk2FEA70G4hvMwe51qMN/RAJbknXG23bSlObuTCN7Ndj1aJGH6/L+hdwfLpUtJm4QYVazzW7D" +
+		"FD27EpSQEqA8bX9+8m1rLg=="
+)
+
+var (
+	certificate *x509.Certificate
+)
+
+func init() {
+	certificate, _ = utils.LoadCertificate(testWechatPayVerifierPlatformCertificate)
+}
+
+func TestWechatPayVerifier_Verify(t *testing.T) {
+	type fields struct {
+		Certificates map[string]*x509.Certificate
+	}
+	type args struct {
+		ctx          context.Context
+		serialNumber string
+		message      string
+		signature    string
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "verify success",
+			fields: fields{
+				Certificates: map[string]*x509.Certificate{testWechatPayVerifierPlatformSerialNumber: certificate},
+			},
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testWechatPayVerifierPlatformSerialNumber,
+				signature:    testExpectedSignature,
+				message:      "source",
+			},
+			wantErr: false,
+		},
+		{
+			name: "verify failed",
+			fields: fields{
+				Certificates: map[string]*x509.Certificate{testWechatPayVerifierPlatformSerialNumber: certificate},
+			},
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testWechatPayVerifierPlatformSerialNumber,
+				signature:    testExpectedSignature,
+				message:      "wrong source",
+			},
+			wantErr: true,
+		},
+		{
+			name: "verify failed with null context",
+			fields: fields{
+				Certificates: map[string]*x509.Certificate{testWechatPayVerifierPlatformSerialNumber: certificate},
+			},
+			args: args{
+				ctx:          nil,
+				serialNumber: testWechatPayVerifierPlatformSerialNumber,
+				signature:    testExpectedSignature,
+				message:      "source",
+			},
+			wantErr: true,
+		},
+		{
+			name: "verify failed with empty serialNumber",
+			fields: fields{
+				Certificates: map[string]*x509.Certificate{testWechatPayVerifierPlatformSerialNumber: certificate},
+			},
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: "",
+				signature:    testExpectedSignature,
+				message:      "source",
+			},
+			wantErr: true,
+		},
+		{
+			name: "verify failed with empty message",
+			fields: fields{
+				Certificates: map[string]*x509.Certificate{testWechatPayVerifierPlatformSerialNumber: certificate},
+			},
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testWechatPayVerifierPlatformSerialNumber,
+				signature:    testExpectedSignature,
+				message:      "",
+			},
+			wantErr: true,
+		},
+		{
+			name: "verify failed with empty signature",
+			fields: fields{
+				Certificates: map[string]*x509.Certificate{testWechatPayVerifierPlatformSerialNumber: certificate},
+			},
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testWechatPayVerifierPlatformSerialNumber,
+				signature:    "",
+				message:      "source",
+			},
+			wantErr: true,
+		},
+		{
+			name: "verify failed with no cert getter",
+			fields: fields{
+				Certificates: nil,
+			},
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testWechatPayVerifierPlatformSerialNumber,
+				signature:    testExpectedSignature,
+				message:      "source",
+			},
+			wantErr: true,
+		},
+		{
+			name: "verify failed with non-base64 signature",
+			fields: fields{
+				Certificates: map[string]*x509.Certificate{testWechatPayVerifierPlatformSerialNumber: certificate},
+			},
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testWechatPayVerifierPlatformSerialNumber,
+				signature:    "invalid base64 signature",
+				message:      "source",
+			},
+			wantErr: true,
+		},
+		{
+			name:   "verify failed with no corresponding certificate",
+			fields: fields{Certificates: map[string]*x509.Certificate{}},
+			args: args{
+				ctx:          context.Background(),
+				serialNumber: testWechatPayVerifierPlatformSerialNumber,
+				signature:    testExpectedSignature,
+				message:      "source",
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var verifier auth.Verifier
+			if tt.fields.Certificates == nil {
+				verifier = NewSHA256WithRSAVerifier(nil)
+			} else {
+				verifier = NewSHA256WithRSAVerifier(core.NewCertificateMap(tt.fields.Certificates))
+			}
+			if err := verifier.Verify(tt.args.ctx, tt.args.serialNumber, tt.args.message,
+				tt.args.signature); (err != nil) != tt.wantErr {
+				t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}

+ 76 - 0
core/certificate_map.go

@@ -0,0 +1,76 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package core
+
+import (
+	"context"
+	"crypto/x509"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+// CertificateMap 最简单的证书获取器——证书Map
+type CertificateMap struct {
+	m      map[string]*x509.Certificate
+	newest string
+}
+
+// Reset 完整重设 CertificateMap 中存储的证书,并重新选择最新的证书
+func (m *CertificateMap) Reset(newCertificates map[string]*x509.Certificate) {
+	var (
+		newestCert     *x509.Certificate
+		newestSerialNo string
+	)
+
+	m.m = make(map[string]*x509.Certificate)
+
+	for serialNo, cert := range newCertificates {
+		m.m[serialNo] = cert
+		if newestSerialNo == "" || newestCert == nil || cert.NotBefore.After(newestCert.NotBefore) {
+			newestSerialNo = serialNo
+			newestCert = cert
+		}
+	}
+
+	m.newest = newestSerialNo
+}
+
+// Get 获取证书序列号对应的平台证书
+func (m *CertificateMap) Get(_ context.Context, serialNumber string) (*x509.Certificate, bool) {
+	cert, ok := m.m[serialNumber]
+	return cert, ok
+}
+
+// GetAll 获取平台证书Map
+func (m *CertificateMap) GetAll(_ context.Context) map[string]*x509.Certificate {
+	ret := make(map[string]*x509.Certificate)
+
+	for serialNo, cert := range m.m {
+		ret[serialNo] = cert
+	}
+
+	return ret
+}
+
+// GetNewestSerial 获取最新的平台证书的证书序列号
+func (m *CertificateMap) GetNewestSerial(_ context.Context) string {
+	return m.newest
+}
+
+// NewCertificateMap 使用 证书序列号->证书 映射 初始化 CertificateMap
+func NewCertificateMap(certificateMap map[string]*x509.Certificate) *CertificateMap {
+	m := CertificateMap{}
+	m.Reset(certificateMap)
+
+	return &m
+}
+
+// NewCertificateMapWithList 使用 证书列表 初始化 CertificateMap
+func NewCertificateMapWithList(l []*x509.Certificate) *CertificateMap {
+	m := make(map[string]*x509.Certificate)
+	for _, c := range l {
+		m[utils.GetCertificateSerialNumber(*c)] = c
+	}
+
+	return NewCertificateMap(m)
+}

+ 32 - 0
core/certificate_visitor.go

@@ -0,0 +1,32 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package core
+
+import (
+	"context"
+	"crypto/x509"
+)
+
+// CertificateGetter 平台证书提供器
+type CertificateGetter interface {
+	// Get 获取证书序列号对应的平台证书
+	Get(ctx context.Context, serialNumber string) (*x509.Certificate, bool)
+	// GetAll 获取平台证书Map
+	GetAll(ctx context.Context) map[string]*x509.Certificate
+	// GetNewestSerial 获取最新的平台证书的证书序列号
+	GetNewestSerial(ctx context.Context) string
+}
+
+// CertificateExporter 平台证书导出器,可获取平台证书内容,
+type CertificateExporter interface {
+	// Export 获取证书序列号对应的平台证书内容
+	Export(ctx context.Context, serialNumber string) (string, bool)
+	// ExportAll 获取平台证书内容Map
+	ExportAll(ctx context.Context) map[string]string
+}
+
+// CertificateVisitor 证书访问器,集 CertificateGetter 与 CertificateExporter 于一体
+type CertificateVisitor interface {
+	CertificateGetter
+	CertificateExporter
+}

+ 14 - 0
core/cipher/cipher.go

@@ -0,0 +1,14 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package cipher
+
+import "context"
+
+// Cipher 使用证书对数据中进行原地加密/使用私钥对数据进行原地解密的功能
+type Cipher interface {
+	// Encrypt 使用证书对数据进行原地加密,密文会直接体现在入参 in 中,并返回加密所使用的证书序列号
+	Encrypt(ctx context.Context, in interface{}) (string, error)
+
+	// Decrypt 使用私钥对数据进行原地解密,明文会直接体现在入参 in 中,无返回
+	Decrypt(ctx context.Context, in interface{}) error
+}

+ 31 - 0
core/cipher/ciphers/context.go

@@ -0,0 +1,31 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package ciphers
+
+import "context"
+
+// contextKey WechatPayCipher Context Key Type
+//
+// 使用强类型避免与其他 Context Key 冲突
+type contextKey string
+
+// String contextKey 的字符串描述,区分普通字符串
+func (c contextKey) String() string {
+	return "WPCipherContext(" + string(c) + ")"
+}
+
+const (
+	// 加密使用的微信支付平台证书序列号
+	contextKeyEncryptSerial contextKey = "EncryptSerial"
+)
+
+// setEncryptSerial 往Context中写入用于加密的证书序列号,返回更新后的Context
+func setEncryptSerial(ctx context.Context, serial string) context.Context {
+	return context.WithValue(ctx, contextKeyEncryptSerial, serial)
+}
+
+// getEncryptSerial 从Context中读取用于加密的证书序列号
+func getEncryptSerial(ctx context.Context) (string, bool) {
+	serial, ok := ctx.Value(contextKeyEncryptSerial).(string)
+	return serial, ok
+}

+ 225 - 0
core/cipher/ciphers/wechat_pay_cipher.go

@@ -0,0 +1,225 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package ciphers
+
+import (
+	"context"
+	"fmt"
+	"reflect"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core/cipher"
+)
+
+type cipherType string
+
+const (
+	cipherTypeEncrypt cipherType = "encrypt"
+	cipherTypeDecrypt cipherType = "decrypt"
+)
+
+const (
+	fieldTagEncryption  = "encryption"
+	encryptionTypeAPIV3 = "EM_APIV3"
+)
+
+// fieldCipherFuncType 用于对特定类型字段进行加/解密的方法类型
+type fieldCipherFuncType func(*WechatPayCipher, context.Context, cipherType, reflect.StructField, reflect.Value) error
+
+// fieldCipherMap 对不同类型字段进行加/解密的方法字典
+var fieldCipherMap map[reflect.Kind]fieldCipherFuncType
+
+func init() {
+	// 初始化加/解密方法字典,使用 init 初始化而不是直接在声明时初始化的原因是为了避免初始化循环依赖
+	fieldCipherMap = map[reflect.Kind]fieldCipherFuncType{
+		reflect.Struct: (*WechatPayCipher).cipherStructField,
+		reflect.Array:  (*WechatPayCipher).cipherArrayField,
+		reflect.Slice:  (*WechatPayCipher).cipherArrayField,
+		reflect.String: (*WechatPayCipher).cipherStringField,
+	}
+}
+
+// WechatPayCipher 提供微信支付敏感信息加解密功能
+//
+// 为了保证通信过程中敏感信息字段(如用户的住址、银行卡号、手机号码等)的机密性,微信支付API v3要求:
+//  1. 商户对上送的敏感信息字段进行加密
+//  2. 微信支付对下行的敏感信息字段进行加密
+// 详见:https://wechatpay-api.gitbook.io/wechatpay-api-v3/qian-ming-zhi-nan-1/min-gan-xin-xi-jia-mi
+type WechatPayCipher struct {
+	encryptor cipher.Encryptor
+	decryptor cipher.Decryptor
+}
+
+// Encrypt 对结构中的敏感字段进行加密
+func (c *WechatPayCipher) Encrypt(ctx context.Context, in interface{}) (string, error) {
+	serial, err := c.encryptor.SelectCertificate(ctx)
+	if err != nil {
+		return "", err
+	}
+
+	ctx = setEncryptSerial(ctx, serial)
+	if v, ok := in.(reflect.Value); ok {
+		err = c.cipher(ctx, cipherTypeEncrypt, v)
+	} else {
+		err = c.cipher(ctx, cipherTypeEncrypt, reflect.ValueOf(in))
+	}
+	if err != nil {
+		return "", fmt.Errorf("encrypt struct failed: %w", err)
+	}
+
+	return serial, nil
+}
+
+// Decrypt 对结构中的敏感字段进行解密
+func (c *WechatPayCipher) Decrypt(ctx context.Context, in interface{}) error {
+	var err error
+	if v, ok := in.(reflect.Value); ok {
+		err = c.cipher(ctx, cipherTypeDecrypt, v)
+	} else {
+		err = c.cipher(ctx, cipherTypeDecrypt, reflect.ValueOf(in))
+	}
+
+	if err != nil {
+		return fmt.Errorf("decrypt struct failed: %w", err)
+	}
+
+	return nil
+}
+
+// cipher 执行加/解密的入口函数
+func (c *WechatPayCipher) cipher(ctx context.Context, ty cipherType, v reflect.Value) error {
+	var isNil bool
+	if v, isNil = derefPtrValue(v); isNil {
+		// No cipher required for nil ptr
+		return nil
+	}
+
+	if !v.CanSet() {
+		return fmt.Errorf("in-place cipher requires settable input, ptr for example")
+	}
+
+	if v.Type().Kind() != reflect.Struct {
+		return fmt.Errorf("only struct can be ciphered")
+	}
+
+	return c.cipherStruct(ctx, ty, v)
+}
+
+// cipherStruct 递归进行Struct的加/解密操作
+func (c *WechatPayCipher) cipherStruct(ctx context.Context, ty cipherType, v reflect.Value) error {
+	var t = v.Type()
+
+	for i := 0; i < t.NumField(); i++ {
+		field := t.Field(i)
+		fieldValue := v.Field(i)
+
+		if err := c.cipherField(ctx, ty, field, fieldValue); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// derefPtrValue 将 Ptr 类型的 Value 解引用,直到获得非指针内容。
+//
+// 如果输入的 Value 不是 Ptr,则返回其本身
+// 如果输入的 Value 最终指向 Nil,则返回最终指向 Nil 的 Value 对象,且 isNil 为 true
+func derefPtrValue(inValue reflect.Value) (outValue reflect.Value, isNil bool) {
+	v := inValue
+
+	for v.Type().Kind() == reflect.Ptr {
+		if v.IsNil() {
+			return v, true
+		}
+		v = v.Elem()
+	}
+
+	return v, false
+}
+
+// cipherField 对字段进行加/解密
+func (c *WechatPayCipher) cipherField(
+	ctx context.Context, ty cipherType, field reflect.StructField, fieldValue reflect.Value,
+) error {
+	if !fieldValue.CanInterface() {
+		// Skip Unexported Field
+		return nil
+	}
+
+	var isNil bool
+	if fieldValue, isNil = derefPtrValue(fieldValue); isNil {
+		// Skip Field with no data
+		return nil
+	}
+
+	if fieldCipherFunc, ok := fieldCipherMap[fieldValue.Type().Kind()]; ok {
+		return fieldCipherFunc(c, ctx, ty, field, fieldValue)
+	}
+
+	return nil
+}
+
+// cipherStructField 对Struct类型的字段进行加/解密
+func (c *WechatPayCipher) cipherStructField(
+	ctx context.Context, ty cipherType, field reflect.StructField, fieldValue reflect.Value,
+) error {
+	_ = field
+	return c.cipherStruct(ctx, ty, fieldValue)
+}
+
+// cipherArrayField 对Array/Slice类型的字段进行加/解密
+func (c *WechatPayCipher) cipherArrayField(
+	ctx context.Context, ty cipherType, field reflect.StructField, fieldValue reflect.Value,
+) error {
+	elemType := fieldValue.Type().Elem()
+	if _, ok := fieldCipherMap[elemType.Kind()]; !ok {
+		// Field Element Type Requires no encryption, skip
+		return nil
+	}
+
+	for j := 0; j < fieldValue.Len(); j++ {
+		elemValue := fieldValue.Index(j)
+		if err := c.cipherField(ctx, ty, field, elemValue); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// cipherStringField 对String类型的字段进行加/解密
+func (c *WechatPayCipher) cipherStringField(
+	ctx context.Context, ty cipherType, field reflect.StructField, fieldValue reflect.Value,
+) error {
+	if field.Tag.Get(fieldTagEncryption) != encryptionTypeAPIV3 {
+		return nil
+	}
+
+	var cipherText string
+	var err error
+
+	switch ty {
+	case cipherTypeEncrypt:
+		serial, ok := getEncryptSerial(ctx)
+		if !ok {
+			// 前置逻辑已经设置了 EncryptSerial,这里正常来讲不会进入
+			return fmt.Errorf("`%s` not provided in ctx(should not happen)", contextKeyEncryptSerial)
+		}
+		cipherText, err = c.encryptor.Encrypt(ctx, serial, fieldValue.Interface().(string))
+	case cipherTypeDecrypt:
+		cipherText, err = c.decryptor.Decrypt(ctx, fieldValue.Interface().(string))
+	default:
+		// 前置逻辑不会设置其他类型,这里正常来讲不会进入
+		return fmt.Errorf("invalid cipher type:%v(should not happen)", ty)
+	}
+
+	if err != nil {
+		return err
+	}
+	fieldValue.SetString(cipherText)
+	return nil
+}
+
+// NewWechatPayCipher 使用 cipher.Encryptor + cipher.Decryptor 构建一个 WechatPayCipher
+func NewWechatPayCipher(encryptor cipher.Encryptor, decryptor cipher.Decryptor) *WechatPayCipher {
+	return &WechatPayCipher{encryptor: encryptor, decryptor: decryptor}
+}

+ 353 - 0
core/cipher/ciphers/wechat_pay_cipher_test.go

@@ -0,0 +1,353 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package ciphers
+
+import (
+	"context"
+	"github.com/agiledragon/gomonkey"
+	"reflect"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/cipher/decryptors"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/cipher/encryptors"
+)
+
+type Student struct {
+	Name      string `encryption:"EM_APIV3"`
+	Age       int
+	Addresses []Address
+	Parents   *[]Parent
+	// unexported secret
+	secret string `encryption:"EM_APIV3"`
+	IDs    []int
+}
+
+type Address struct {
+	// No Tag
+	Country *string
+	// Not EM_APIV3 encryption Tag
+	Province *string `encryption:"EM_APIV2"`
+	// EM_APIV3 encryption Tag
+	City   **string `encryption:"EM_APIV3"`
+	Street *string  `encryption:"EM_APIV3"`
+}
+
+type Parent struct {
+	Name        string  `encryption:"EM_APIV3"`
+	PhoneNumber *string `encryption:"EM_APIV3"`
+}
+
+func TestContextKey_String(t *testing.T) {
+	assert.Equal(t, "WPCipherContext(EncryptSerial)", contextKeyEncryptSerial.String())
+}
+
+func TestWechatPayCipher_Encrypt_Decrypt(t *testing.T) {
+	cityCD := core.String("成都")
+	cityLA := core.String("LA")
+
+	s := Student{
+		Name: "小可",
+		Age:  8,
+		Addresses: []Address{
+			{
+				Country:  core.String("中国"),
+				Province: core.String("四川"),
+				City:     &cityCD,
+				Street:   core.String("春熙路"),
+			},
+			{
+				Country:  core.String("USA"),
+				Province: core.String("California"),
+				City:     &cityLA,
+				Street:   core.String("Nowhere"),
+			},
+		},
+		Parents: &[]Parent{
+			{
+				Name:        "爸",
+				PhoneNumber: core.String("13000000000"),
+			},
+			{
+				Name:        "妈",
+				PhoneNumber: nil,
+			},
+		},
+		secret: "this is secret",
+		IDs: []int{
+			12345,
+			54321,
+		},
+	}
+
+	c := WechatPayCipher{
+		encryptor: &encryptors.MockEncryptor{
+			Serial: "Mock Serial",
+		},
+		decryptor: &decryptors.MockDecryptor{},
+	}
+
+	serial, err := c.Encrypt(context.Background(), &s)
+	assert.Equal(t, "Mock Serial", serial)
+	require.NoError(t, err)
+	assert.Equal(t, "Encrypted小可", s.Name)
+	assert.Equal(t, 8, s.Age)
+	assert.Equal(t, "中国", *(s.Addresses[0].Country))
+	assert.Equal(t, "四川", *(s.Addresses[0].Province))
+	assert.Equal(t, "Encrypted成都", **(s.Addresses[0].City))
+	assert.Equal(t, "Encrypted春熙路", *(s.Addresses[0].Street))
+	assert.Equal(t, "USA", *(s.Addresses[1].Country))
+	assert.Equal(t, "California", *(s.Addresses[1].Province))
+	assert.Equal(t, "EncryptedLA", **(s.Addresses[1].City))
+	assert.Equal(t, "EncryptedNowhere", *(s.Addresses[1].Street))
+	assert.Equal(t, "Encrypted爸", (*s.Parents)[0].Name)
+	assert.Equal(t, "Encrypted13000000000", *((*s.Parents)[0].PhoneNumber))
+	assert.Equal(t, "Encrypted妈", (*s.Parents)[1].Name)
+	assert.Equal(t, (*string)(nil), (*s.Parents)[1].PhoneNumber)
+	assert.Equal(t, "this is secret", s.secret) // unexported fields will be skipped
+	assert.Equal(t, 12345, s.IDs[0])
+	assert.Equal(t, 54321, s.IDs[1])
+
+	err = c.Decrypt(context.Background(), &s)
+	require.NoError(t, err)
+	assert.Equal(t, "小可", s.Name)
+	assert.Equal(t, 8, s.Age)
+	assert.Equal(t, "中国", *(s.Addresses[0].Country))
+	assert.Equal(t, "四川", *(s.Addresses[0].Province))
+	assert.Equal(t, "成都", **(s.Addresses[0].City))
+	assert.Equal(t, "春熙路", *(s.Addresses[0].Street))
+	assert.Equal(t, "USA", *(s.Addresses[1].Country))
+	assert.Equal(t, "California", *(s.Addresses[1].Province))
+	assert.Equal(t, "LA", **(s.Addresses[1].City))
+	assert.Equal(t, "Nowhere", *(s.Addresses[1].Street))
+	assert.Equal(t, "爸", (*s.Parents)[0].Name)
+	assert.Equal(t, "13000000000", *((*s.Parents)[0].PhoneNumber))
+	assert.Equal(t, "妈", (*s.Parents)[1].Name)
+	assert.Equal(t, (*string)(nil), (*s.Parents)[1].PhoneNumber)
+	assert.Equal(t, "this is secret", s.secret) // unexported fields will be skipped
+	assert.Equal(t, 12345, s.IDs[0])
+	assert.Equal(t, 54321, s.IDs[1])
+}
+
+func TestWechatPayCipher_Encrypt_DecryptWithValue(t *testing.T) {
+	cityCD := core.String("成都")
+	cityLA := core.String("LA")
+
+	s := Student{
+		Name: "小可",
+		Age:  8,
+		Addresses: []Address{
+			{
+				Country:  core.String("中国"),
+				Province: core.String("四川"),
+				City:     &cityCD,
+				Street:   core.String("春熙路"),
+			},
+			{
+				Country:  core.String("USA"),
+				Province: core.String("California"),
+				City:     &cityLA,
+				Street:   core.String("Nowhere"),
+			},
+		},
+		Parents: &[]Parent{
+			{
+				Name:        "爸",
+				PhoneNumber: core.String("13000000000"),
+			},
+			{
+				Name:        "妈",
+				PhoneNumber: nil,
+			},
+		},
+	}
+
+	c := NewWechatPayCipher(
+		&encryptors.MockEncryptor{
+			Serial: "Mock Serial",
+		},
+		&decryptors.MockDecryptor{},
+	)
+
+	serial, err := c.Encrypt(context.Background(), reflect.ValueOf(&s))
+	assert.Equal(t, "Mock Serial", serial)
+	require.NoError(t, err)
+	assert.Equal(t, "Encrypted小可", s.Name)
+	assert.Equal(t, 8, s.Age)
+	assert.Equal(t, "中国", *(s.Addresses[0].Country))
+	assert.Equal(t, "四川", *(s.Addresses[0].Province))
+	assert.Equal(t, "Encrypted成都", **(s.Addresses[0].City))
+	assert.Equal(t, "Encrypted春熙路", *(s.Addresses[0].Street))
+	assert.Equal(t, "USA", *(s.Addresses[1].Country))
+	assert.Equal(t, "California", *(s.Addresses[1].Province))
+	assert.Equal(t, "EncryptedLA", **(s.Addresses[1].City))
+	assert.Equal(t, "EncryptedNowhere", *(s.Addresses[1].Street))
+	assert.Equal(t, "Encrypted爸", (*s.Parents)[0].Name)
+	assert.Equal(t, "Encrypted13000000000", *((*s.Parents)[0].PhoneNumber))
+	assert.Equal(t, "Encrypted妈", (*s.Parents)[1].Name)
+	assert.Equal(t, (*string)(nil), (*s.Parents)[1].PhoneNumber)
+
+	err = c.Decrypt(context.Background(), reflect.ValueOf(&s))
+	require.NoError(t, err)
+	assert.Equal(t, "小可", s.Name)
+	assert.Equal(t, 8, s.Age)
+	assert.Equal(t, "中国", *(s.Addresses[0].Country))
+	assert.Equal(t, "四川", *(s.Addresses[0].Province))
+	assert.Equal(t, "成都", **(s.Addresses[0].City))
+	assert.Equal(t, "春熙路", *(s.Addresses[0].Street))
+	assert.Equal(t, "USA", *(s.Addresses[1].Country))
+	assert.Equal(t, "California", *(s.Addresses[1].Province))
+	assert.Equal(t, "LA", **(s.Addresses[1].City))
+	assert.Equal(t, "Nowhere", *(s.Addresses[1].Street))
+	assert.Equal(t, "爸", (*s.Parents)[0].Name)
+	assert.Equal(t, "13000000000", *((*s.Parents)[0].PhoneNumber))
+	assert.Equal(t, "妈", (*s.Parents)[1].Name)
+	assert.Equal(t, (*string)(nil), (*s.Parents)[1].PhoneNumber)
+}
+
+func TestWechatPayCipher_CipherNil(t *testing.T) {
+	c := WechatPayCipher{
+		encryptor: &encryptors.MockEncryptor{
+			Serial: "Mock Serial",
+		},
+		decryptor: &decryptors.MockDecryptor{},
+	}
+
+	var s *Student
+
+	_, err := c.Encrypt(context.Background(), s)
+	require.NoError(t, err)
+
+	err = c.Decrypt(context.Background(), &s)
+	require.NoError(t, err)
+}
+
+func TestWechatPayCipher_CipherNonStruct(t *testing.T) {
+	c := WechatPayCipher{
+		encryptor: &encryptors.MockEncryptor{
+			Serial: "Mock Serial",
+		},
+		decryptor: &decryptors.MockDecryptor{},
+	}
+
+	_, err := c.Encrypt(context.Background(), core.String("123"))
+	require.Error(t, err)
+	assert.Equal(t, "encrypt struct failed: only struct can be ciphered", err.Error())
+
+	err = c.Decrypt(context.Background(), core.Int64(123))
+	require.Error(t, err)
+	assert.Equal(t, "decrypt struct failed: only struct can be ciphered", err.Error())
+}
+
+func TestWechatPayCipher_CipherValue(t *testing.T) {
+	s := Student{
+		Name: "小可",
+		Age:  8,
+	}
+
+	c := WechatPayCipher{
+		encryptor: &encryptors.MockEncryptor{
+			Serial: "Mock Serial",
+		},
+		decryptor: &decryptors.MockDecryptor{},
+	}
+
+	_, err := c.Encrypt(context.Background(), s)
+	require.Error(t, err)
+	assert.Equal(t, "encrypt struct failed: in-place cipher requires settable input, ptr for example", err.Error())
+
+	err = c.Decrypt(context.Background(), s)
+	require.Error(t, err)
+	assert.Equal(t, "decrypt struct failed: in-place cipher requires settable input, ptr for example", err.Error())
+}
+
+func TestWechatPayCipher_EncryptWithoutCertificate(t *testing.T) {
+	s := Student{Name: "小可"}
+
+	// 这是一个 SelectCertificate 会失败的 Encryptor
+	invalidEncryptor := encryptors.NewWechatPayEncryptor(core.NewCertificateMap(nil))
+
+	c := WechatPayCipher{
+		encryptor: invalidEncryptor,
+		decryptor: &decryptors.MockDecryptor{},
+	}
+
+	_, err := c.Encrypt(context.Background(), s)
+	assert.Error(t, err)
+}
+
+func TestWechatPayCipher_EncryptWithoutSerial(t *testing.T) {
+	patch := gomonkey.ApplyFunc(getEncryptSerial, func(ctx context.Context) (string, bool) {
+		return "", false
+	})
+	defer patch.Reset()
+	s := Student{
+		Name: "小可",
+		Age:  8,
+	}
+
+	c := WechatPayCipher{
+		encryptor: &encryptors.MockEncryptor{
+			Serial: "Mock Serial",
+		},
+		decryptor: &decryptors.MockDecryptor{},
+	}
+
+	_, err := c.Encrypt(context.Background(), &s)
+	assert.Error(t, err)
+}
+
+func TestWechatPayCipher_DecryptWrongData(t *testing.T) {
+	s := Student{
+		Name: "NotEncrypted小可",
+		Age:  8,
+	}
+
+	c := WechatPayCipher{
+		encryptor: &encryptors.MockEncryptor{
+			Serial: "Mock Serial",
+		},
+		decryptor: &decryptors.MockDecryptor{},
+	}
+
+	err := c.Decrypt(context.Background(), &s)
+	assert.Error(t, err)
+
+	s = Student{
+		Name: "Encrypted小可",
+		Addresses: []Address{
+			{
+				Country:  core.String("中国"),
+				Province: core.String("四川"),
+				Street:   core.String("UnEncrypted春熙路"),
+			},
+			{
+				Country:  core.String("USA"),
+				Province: core.String("California"),
+				Street:   core.String("EncryptedNowhere"),
+			},
+		},
+	}
+
+	err = c.Decrypt(context.Background(), &s)
+	assert.Error(t, err)
+}
+
+func TestWechatPayCipher_cipherWithWrongType(t *testing.T) {
+	s := Student{
+		Name: "Encrypted小可",
+		Age:  8,
+	}
+
+	c := WechatPayCipher{
+		encryptor: &encryptors.MockEncryptor{
+			Serial: "Mock Serial",
+		},
+		decryptor: &decryptors.MockDecryptor{},
+	}
+
+	err := c.cipher(context.Background(), cipherType("invalid"), reflect.ValueOf(&s))
+	assert.Error(t, err)
+}

+ 11 - 0
core/cipher/decryptor.go

@@ -0,0 +1,11 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package cipher
+
+import "context"
+
+// Decryptor 字符串解密器
+type Decryptor interface {
+	// Decrypt 对字符串解密
+	Decrypt(ctx context.Context, ciphertext string) (plaintext string, err error)
+}

+ 23 - 0
core/cipher/decryptors/mock_decryptor.go

@@ -0,0 +1,23 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package decryptors
+
+import (
+	"context"
+	"fmt"
+	"strings"
+)
+
+// MockDecryptor 模拟字符串解密器
+type MockDecryptor struct {
+}
+
+// Decrypt 对字符串进行模拟解密
+func (d *MockDecryptor) Decrypt(ctx context.Context, ciphertext string) (plaintext string, err error) {
+	fmt.Printf("[MockDecryptor] Decrypting `%v`\n", ciphertext)
+	if !strings.HasPrefix(ciphertext, "Encrypted") {
+		return ciphertext, fmt.Errorf("cannot decrypt invalid cipher string:`%v`", ciphertext)
+	}
+
+	return strings.TrimPrefix(ciphertext, "Encrypted"), nil
+}

+ 29 - 0
core/cipher/decryptors/wechat_pay_decryptor.go

@@ -0,0 +1,29 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package decryptors
+
+import (
+	"context"
+	"crypto/rsa"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+// WechatPayDecryptor 微信支付字符串解密器
+type WechatPayDecryptor struct {
+	// 商户私钥
+	privateKey *rsa.PrivateKey
+}
+
+// Decrypt 使用商户私钥对字符串进行解密
+func (d *WechatPayDecryptor) Decrypt(_ context.Context, ciphertext string) (plaintext string, err error) {
+	if ciphertext == "" {
+		return "", nil
+	}
+	return utils.DecryptOAEP(ciphertext, d.privateKey)
+}
+
+// NewWechatPayDecryptor 使用商户私钥初始化一个 WechatPayDecryptor
+func NewWechatPayDecryptor(privateKey *rsa.PrivateKey) *WechatPayDecryptor {
+	return &WechatPayDecryptor{privateKey: privateKey}
+}

+ 71 - 0
core/cipher/decryptors/wechat_pay_decryptor_test.go

@@ -0,0 +1,71 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package decryptors
+
+import (
+	"context"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+const (
+	testPrivateKey = `-----BEGIN TESTING KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZUJN33V+dSfvd
+fL0Mu+39XrZNXFFMQSy1V15FpncHeV47SmV0TzTqZc7hHB0ddqAdDi8Z5k3TKqb7
+6sOwYr5TcAfuR6PIPaleyE0/0KrljBum2Isa2Nyq7Dgc3ElBQ6YN4l/a+DpvKaz1
+FSKmKrhLNskqokWVSlu4g8OlKlbPXQ9ibII14MZRQrrkTmHYHzfi7GXXM0thAKuR
+0HNvyhTHBh4/lrYM3GaMvmWwkwvsMavnOex6+eioZHBOb1/EIZ/LzC6zuHArPpyW
+3daGaZ1rtQB1vVzTyERAVVFsXXgBHvfFud3w3ShsJYk8JvMwK2RpJ5/gV0QSARcm
+LDRUAlPzAgMBAAECggEBAMc7rDeUaXiWv6bMGbZ3BTXpg1FhdddnWUnYE8HfX/km
+OFI7XtBHXcgYFpcjYz4D5787pcsk7ezPidAj58zqenuclmjKnUmT3pfbI5eCA2v4
+C9HnbYDrmUPK1ZcADtka4D6ScDccpNYNa1g2TFHzkIrEa6H+q7S3O2fqxY/DRVtN
+0JIXalBb8daaqL5QVzSmM2BMVnHy+YITJWIkP2a3pKs9C0W65JGDsnG0wVrHinHF
++cnhFZIbaPEI//DAFMc9NkrWOKVRTEgcCUxCFaHOZVNxDWZD7A2ZfJB2rK6eg//y
+gEiFDR2h6mTaDowMB4YF2n2dsIO4/dCG8vPHI20jn4ECgYEA/ZGu6lEMlO0XZnam
+AZGtiNgLcCfM/C2ZERZE7QTRPZH1WdK92Al9ndldsswFw4baJrJLCmghjF/iG4zi
+hhBvLnOLksnZUfjdumxoHDWXo2QBWbI5QsWIE7AuTiWgWj1I7X4fCXSQf6i+M/y2
+6TogQ7d0ANpZFyOkTNMn/tiJvLECgYEA22XqlamG/yfAGWery5KNH2DGlTIyd6xJ
+WtJ9j3jU99lZ0bCQ5xhiBbU9ImxCi3zgTsoqLWgA/p00HhNFNoUcTl9ofc0G3zwT
+D1y0ZzcnVKxGJdZ6ohW52V0hJStAigtjYAsUgjm7//FH7PiQDBDP1Wa6xSRkDQU/
+aSbQxvEE8+MCgYEA3bb8krW7opyM0XL9RHH0oqsFlVO30Oit5lrqebS0oHl3Zsr2
+ZGgoBlWBsEzk3UqUhTFwm/DhJLTSJ/TQPRkxnhQ5/mewNhS9C7yua7wQkzVmWN+V
+YeUGTvDGDF6qDz12/vJAgSwDDRym8x4NcXD5tTw7mmNRcwIfL22SkysThIECgYAV
+BgccoEoXWS/HP2/u6fQr9ZIR6eV8Ij5FPbZacTG3LlS1Cz5XZra95UgebFFUHHtC
+EY1JHJY7z8SWvTH8r3Su7eWNaIAoFBGffzqqSVazfm6aYZsOvRY6BfqPHT3p/H1h
+Tq6AbBffxrcltgvXnCTORjHPglU0CjSxVs7awW3AEQKBgB5WtaC8VLROM7rkfVIq
++RXqE5vtJfa3e3N7W3RqxKp4zHFAPfr82FK5CX2bppEaxY7SEZVvVInKDc5gKdG/
+jWNRBmvvftZhY59PILHO2X5vO4FXh7suEjy6VIh0gsnK36mmRboYIBGsNuDHjXLe
+BDa+8mDLkWu5nHEhOxy2JJZl
+-----END TESTING KEY-----`
+	testCipherText = "k/ASStqPXJikowOxiCddi8QeBhMcpLV4bjVqvNM9jnoqt1MPvbKSAmaEgYhIP07KUC8suaTCKw6IWsng/BIR2wLicf" +
+		"m1m7mPSosC3xnMb2tU0uA/DQTggN+e0G/akhIZ7d9gL6dj56BYkcy/RgTGHvs9ybIoD6dgCWL/pApgZRnowhKU2ZY1bQbBpudyiWmfB" +
+		"E6kZ+zISN8eb9tNOGucOTkkqz7C0BgGkkUAtu/qVUMo9t/597tGUde7uqVSdrV68XA4FTk9Jxjb3mXSB1u+huX99pY/ku/8evvtR/rh" +
+		"tebQNzHmghiFtEDpPQKwdf3m1akilb3Eum/MvV0Ouxn2dg=="
+	testPlainText = "hello world"
+)
+
+func TestWechatPayDecryptor_Decrypt(t *testing.T) {
+	privateKey, err := utils.LoadPrivateKey(testingKey(testPrivateKey))
+	require.NoError(t, err)
+	decryptor := NewWechatPayDecryptor(privateKey)
+
+	plaintext, err := decryptor.Decrypt(context.Background(), testCipherText)
+	require.NoError(t, err)
+	assert.Equal(t, plaintext, testPlainText)
+}
+
+func TestWechatPayDecryptor_DecryptEmpty(t *testing.T) {
+	privateKey, err := utils.LoadPrivateKey(testingKey(testPrivateKey))
+	require.NoError(t, err)
+	decryptor := NewWechatPayDecryptor(privateKey)
+
+	plaintext, err := decryptor.Decrypt(context.Background(), "")
+	require.NoError(t, err)
+	assert.Equal(t, "", plaintext)
+}
+
+func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") }

+ 14 - 0
core/cipher/encryptor.go

@@ -0,0 +1,14 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package cipher
+
+import "context"
+
+// Encryptor 字符串加密器
+type Encryptor interface {
+	// SelectCertificate 选择合适的微信支付平台证书用于加密
+	SelectCertificate(ctx context.Context) (serial string, err error)
+
+	// Encrypt 对字符串加密
+	Encrypt(ctx context.Context, serial, plaintext string) (ciphertext string, err error)
+}

+ 28 - 0
core/cipher/encryptors/mock_encryptor.go

@@ -0,0 +1,28 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package encryptors
+
+import (
+	"context"
+	"fmt"
+)
+
+// MockEncryptor 模拟字符串加密器
+type MockEncryptor struct {
+	Serial string
+}
+
+// SelectCertificate 模拟选择加密用证书
+func (e *MockEncryptor) SelectCertificate(ctx context.Context) (serial string, err error) {
+	return e.Serial, nil
+}
+
+// Encrypt 使用指定证书进行字符串加密
+func (e *MockEncryptor) Encrypt(ctx context.Context, serial, plaintext string) (ciphertext string, err error) {
+	if serial != e.Serial {
+		return plaintext, fmt.Errorf("invalid certificate serial: `%v`", serial)
+	}
+
+	ciphertext = "Encrypted" + plaintext
+	return ciphertext, nil
+}

+ 51 - 0
core/cipher/encryptors/wechat_pay_encryptor.go

@@ -0,0 +1,51 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package encryptors
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+// WechatPayEncryptor 微信支付字符串加密器,使用微信支付平台证书
+type WechatPayEncryptor struct {
+	// 微信支付平台证书提供器
+	certGetter core.CertificateGetter
+}
+
+// NewWechatPayEncryptor 新建一个 WechatPayEncryptor
+func NewWechatPayEncryptor(certProvider core.CertificateGetter) *WechatPayEncryptor {
+	return &WechatPayEncryptor{certGetter: certProvider}
+}
+
+// SelectCertificate 选择合适的微信支付平台证书用于加密
+// 根据微信支付平台证书文档说明,应优先使用最新的证书(即启用时间最晚)
+// https://wechatpay-api.gitbook.io/wechatpay-api-v3/jie-kou-wen-dang/ping-tai-zheng-shu#zhu-yi-shi-xiang
+func (e *WechatPayEncryptor) SelectCertificate(ctx context.Context) (serial string, err error) {
+	newestSerial := e.certGetter.GetNewestSerial(ctx)
+	if newestSerial == "" {
+		return "", fmt.Errorf("no certificate for encryption")
+	}
+
+	return newestSerial, nil
+}
+
+// Encrypt 对字符串加密
+func (e *WechatPayEncryptor) Encrypt(
+	ctx context.Context, serial, plaintext string) (ciphertext string, err error) {
+	cert, ok := e.certGetter.Get(ctx, serial)
+
+	if !ok {
+		return plaintext, fmt.Errorf("cert for EncryptSerial(%v) not found", serial)
+	}
+
+	// 不需要对空串进行加密
+	if plaintext == "" {
+		return "", nil
+	}
+
+	return utils.EncryptOAEPWithCertificate(plaintext, cert)
+}

+ 193 - 0
core/cipher/encryptors/wechat_pay_encryptor_test.go

@@ -0,0 +1,193 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package encryptors
+
+import (
+	"context"
+	"crypto/x509"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+var testCertStrList = [2]string{
+	// Serial=D7CE59D1F522D701 NotBefore=Apr 27 08:55:23 2021 GMT
+	`-----BEGIN CERTIFICATE-----
+MIIDVzCCAj+gAwIBAgIJANfOWdH1ItcBMA0GCSqGSIb3DQEBCwUAMEIxCzAJBgNV
+BAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQg
+Q29tcGFueSBMdGQwHhcNMjEwNDI3MDg1NTIzWhcNMzEwNDI1MDg1NTIzWjBCMQsw
+CQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZh
+dWx0IENvbXBhbnkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
+2VCTd91fnUn73Xy9DLvt/V62TVxRTEEstVdeRaZ3B3leO0pldE806mXO4RwdHXag
+HQ4vGeZN0yqm++rDsGK+U3AH7kejyD2pXshNP9Cq5YwbptiLGtjcquw4HNxJQUOm
+DeJf2vg6byms9RUipiq4SzbJKqJFlUpbuIPDpSpWz10PYmyCNeDGUUK65E5h2B83
+4uxl1zNLYQCrkdBzb8oUxwYeP5a2DNxmjL5lsJML7DGr5znsevnoqGRwTm9fxCGf
+y8wus7hwKz6clt3Whmmda7UAdb1c08hEQFVRbF14AR73xbnd8N0obCWJPCbzMCtk
+aSef4FdEEgEXJiw0VAJT8wIDAQABo1AwTjAdBgNVHQ4EFgQUT1c7nd/SUO76HSoZ
+umNUJv1R5PwwHwYDVR0jBBgwFoAUT1c7nd/SUO76HSoZumNUJv1R5PwwDAYDVR0T
+BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAfTjxKRQMzNB/U6ZoCUS+BSNfa2Oh
+0plMN6ZuzwiVVZwg1jywvv5yv04koS7Pd4i9E4gt9ZBUQXlpq+A3oOCEEHNRR6b2
+kyazGRM7s0OP5X21WrbpSmKmU6K7hkfx30yYs08LVs/Q8DIhvaj1FCFeJzUCzYn/
+fHMq4tsbKO0dKAeydPM/nrUZBmaYQVKMVOORGLFjFKVO7JV6Kq/R86ouhjEPgJOe
+2xulNBUcjicqtZlBdEh/PWCYP2SpGVDclKm8jeo175T3EVAkdKzzmfpxtMmnMlmq
+cTJOU9TxuGvNASMtjj7pYIerTx+xgZDXEVBWFW9PjJ0TV06tCRsgSHItgg==
+-----END CERTIFICATE-----`,
+	// Serial=F5765756002FDD77 NotBefore=Apr 27 08:40:32 2021 GMT
+	`-----BEGIN CERTIFICATE-----
+MIIDVzCCAj+gAwIBAgIJAPV2V1YAL913MA0GCSqGSIb3DQEBCwUAMEIxCzAJBgNV
+BAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQg
+Q29tcGFueSBMdGQwHhcNMjEwNDI3MDg0MDMyWhcNMzEwNDI1MDg0MDMyWjBCMQsw
+CQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZh
+dWx0IENvbXBhbnkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
+2VCTd91fnUn73Xy9DLvt/V62TVxRTEEstVdeRaZ3B3leO0pldE806mXO4RwdHXag
+HQ4vGeZN0yqm++rDsGK+U3AH7kejyD2pXshNP9Cq5YwbptiLGtjcquw4HNxJQUOm
+DeJf2vg6byms9RUipiq4SzbJKqJFlUpbuIPDpSpWz10PYmyCNeDGUUK65E5h2B83
+4uxl1zNLYQCrkdBzb8oUxwYeP5a2DNxmjL5lsJML7DGr5znsevnoqGRwTm9fxCGf
+y8wus7hwKz6clt3Whmmda7UAdb1c08hEQFVRbF14AR73xbnd8N0obCWJPCbzMCtk
+aSef4FdEEgEXJiw0VAJT8wIDAQABo1AwTjAdBgNVHQ4EFgQUT1c7nd/SUO76HSoZ
+umNUJv1R5PwwHwYDVR0jBBgwFoAUT1c7nd/SUO76HSoZumNUJv1R5PwwDAYDVR0T
+BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAM+tslqBxYwqL9fdvGG6hfy69sjfX
+UhBtBLWYugKKQCOWWLeq5dDWm3i5Cx2Rgiy9uc7RfmJNxQfIKlcoCNP85BjDoG1B
+YnVc6znlcrT9uHgseha3987WwZsFAQbcy8TLUYHzVB8gmDgq8O08xdIe0eczatI8
+t3Rg8WXO6Gs66JJ4JR+rD01o3FiSOQCRWhn19NSyDydsgPlOR2t9B9L+MkJwlsMG
+Krn85TnwL3qcInzRnU8X86faXXJrI0IJi44tECKw8ftngCl6vyNwNNKPDwdkcuuV
+8y3iBixO5IuKxEKEp2wGPV/4W1AXO73Z3Gb7z/1oxdgeO0hVqz1hBasTCQ==
+-----END CERTIFICATE-----`,
+}
+
+const privateKeyStr = `-----BEGIN TESTING KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZUJN33V+dSfvd
+fL0Mu+39XrZNXFFMQSy1V15FpncHeV47SmV0TzTqZc7hHB0ddqAdDi8Z5k3TKqb7
+6sOwYr5TcAfuR6PIPaleyE0/0KrljBum2Isa2Nyq7Dgc3ElBQ6YN4l/a+DpvKaz1
+FSKmKrhLNskqokWVSlu4g8OlKlbPXQ9ibII14MZRQrrkTmHYHzfi7GXXM0thAKuR
+0HNvyhTHBh4/lrYM3GaMvmWwkwvsMavnOex6+eioZHBOb1/EIZ/LzC6zuHArPpyW
+3daGaZ1rtQB1vVzTyERAVVFsXXgBHvfFud3w3ShsJYk8JvMwK2RpJ5/gV0QSARcm
+LDRUAlPzAgMBAAECggEBAMc7rDeUaXiWv6bMGbZ3BTXpg1FhdddnWUnYE8HfX/km
+OFI7XtBHXcgYFpcjYz4D5787pcsk7ezPidAj58zqenuclmjKnUmT3pfbI5eCA2v4
+C9HnbYDrmUPK1ZcADtka4D6ScDccpNYNa1g2TFHzkIrEa6H+q7S3O2fqxY/DRVtN
+0JIXalBb8daaqL5QVzSmM2BMVnHy+YITJWIkP2a3pKs9C0W65JGDsnG0wVrHinHF
++cnhFZIbaPEI//DAFMc9NkrWOKVRTEgcCUxCFaHOZVNxDWZD7A2ZfJB2rK6eg//y
+gEiFDR2h6mTaDowMB4YF2n2dsIO4/dCG8vPHI20jn4ECgYEA/ZGu6lEMlO0XZnam
+AZGtiNgLcCfM/C2ZERZE7QTRPZH1WdK92Al9ndldsswFw4baJrJLCmghjF/iG4zi
+hhBvLnOLksnZUfjdumxoHDWXo2QBWbI5QsWIE7AuTiWgWj1I7X4fCXSQf6i+M/y2
+6TogQ7d0ANpZFyOkTNMn/tiJvLECgYEA22XqlamG/yfAGWery5KNH2DGlTIyd6xJ
+WtJ9j3jU99lZ0bCQ5xhiBbU9ImxCi3zgTsoqLWgA/p00HhNFNoUcTl9ofc0G3zwT
+D1y0ZzcnVKxGJdZ6ohW52V0hJStAigtjYAsUgjm7//FH7PiQDBDP1Wa6xSRkDQU/
+aSbQxvEE8+MCgYEA3bb8krW7opyM0XL9RHH0oqsFlVO30Oit5lrqebS0oHl3Zsr2
+ZGgoBlWBsEzk3UqUhTFwm/DhJLTSJ/TQPRkxnhQ5/mewNhS9C7yua7wQkzVmWN+V
+YeUGTvDGDF6qDz12/vJAgSwDDRym8x4NcXD5tTw7mmNRcwIfL22SkysThIECgYAV
+BgccoEoXWS/HP2/u6fQr9ZIR6eV8Ij5FPbZacTG3LlS1Cz5XZra95UgebFFUHHtC
+EY1JHJY7z8SWvTH8r3Su7eWNaIAoFBGffzqqSVazfm6aYZsOvRY6BfqPHT3p/H1h
+Tq6AbBffxrcltgvXnCTORjHPglU0CjSxVs7awW3AEQKBgB5WtaC8VLROM7rkfVIq
++RXqE5vtJfa3e3N7W3RqxKp4zHFAPfr82FK5CX2bppEaxY7SEZVvVInKDc5gKdG/
+jWNRBmvvftZhY59PILHO2X5vO4FXh7suEjy6VIh0gsnK36mmRboYIBGsNuDHjXLe
+BDa+8mDLkWu5nHEhOxy2JJZl
+-----END TESTING KEY-----`
+
+const publicKeyStr = `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2VCTd91fnUn73Xy9DLvt
+/V62TVxRTEEstVdeRaZ3B3leO0pldE806mXO4RwdHXagHQ4vGeZN0yqm++rDsGK+
+U3AH7kejyD2pXshNP9Cq5YwbptiLGtjcquw4HNxJQUOmDeJf2vg6byms9RUipiq4
+SzbJKqJFlUpbuIPDpSpWz10PYmyCNeDGUUK65E5h2B834uxl1zNLYQCrkdBzb8oU
+xwYeP5a2DNxmjL5lsJML7DGr5znsevnoqGRwTm9fxCGfy8wus7hwKz6clt3Whmmd
+a7UAdb1c08hEQFVRbF14AR73xbnd8N0obCWJPCbzMCtkaSef4FdEEgEXJiw0VAJT
+8wIDAQAB
+-----END PUBLIC KEY-----`
+
+func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") }
+
+func initWechatPayEncryptor() (*WechatPayEncryptor, error) {
+	l := make([]*x509.Certificate, 0, 2)
+	for _, certStr := range testCertStrList {
+		cert, err := utils.LoadCertificate(certStr)
+		if err != nil {
+			return nil, err
+		}
+		l = append(l, cert)
+	}
+
+	return NewWechatPayEncryptor(core.NewCertificateMapWithList(l)), nil
+}
+
+func TestWechatPayEncryptorSelectCertificate(t *testing.T) {
+	e, err := initWechatPayEncryptor()
+	require.NoError(t, err)
+
+	serial, err := e.SelectCertificate(context.Background())
+	require.NoError(t, err)
+	assert.Equal(t, "D7CE59D1F522D701", serial)
+}
+
+func TestWechatPayEncryptorEncrypt(t *testing.T) {
+	e, err := initWechatPayEncryptor()
+	require.NoError(t, err)
+
+	const serial = "F5765756002FDD77"
+	const plaintext = "hello world"
+
+	ciphertext, err := e.Encrypt(context.Background(), serial, plaintext)
+	require.NoError(t, err)
+
+	privateKey, err := utils.LoadPrivateKey(testingKey(privateKeyStr))
+	require.NoError(t, err)
+
+	newPlainText, err := utils.DecryptOAEP(ciphertext, privateKey)
+	require.NoError(t, err)
+	assert.Equal(t, newPlainText, plaintext)
+}
+
+func TestWechatPayEncryptorEncryptEmpty(t *testing.T) {
+	e, err := initWechatPayEncryptor()
+	require.NoError(t, err)
+
+	const serial = "F5765756002FDD77"
+	const plaintext = ""
+
+	ciphertext, err := e.Encrypt(context.Background(), serial, plaintext)
+	require.NoError(t, err)
+	assert.Equal(t, "", ciphertext)
+}
+
+func TestWechatPayEncryptorEncryptWithWrongSerial(t *testing.T) {
+	e, err := initWechatPayEncryptor()
+	require.NoError(t, err)
+
+	const serial = "unknown serial"
+	const plaintext = ""
+
+	_, err = e.Encrypt(context.Background(), serial, plaintext)
+	require.Error(t, err)
+}
+
+func TestMockEncryptorEncrypt(t *testing.T) {
+	e := MockEncryptor{Serial: "F5765756002FDD77"}
+
+	cipertext, err := e.Encrypt(context.Background(), "F5765756002FDD77", "hehe")
+	require.NoError(t, err)
+	assert.Equal(t, "Encryptedhehe", cipertext)
+}
+
+func TestMockEncryptorEncryptWithWrontSerial(t *testing.T) {
+	e := MockEncryptor{Serial: "F5765756002FDD77"}
+
+	_, err := e.Encrypt(context.Background(), "wrong serial", "hehe")
+	require.Error(t, err)
+}
+
+func TestPublicEncryptorEncrypt(t *testing.T) {
+	publicKey, _ := utils.LoadPublicKey(publicKeyStr)
+	e := NewWechatPayPubKeyEncryptor("F5765756002FDD77", *publicKey)
+
+	plaintext := "hehe"
+	ciphertext, err := e.Encrypt(context.Background(), "F5765756002FDD77", plaintext)
+	require.NoError(t, err)
+
+	privateKey, _ := utils.LoadPrivateKey(testingKey(privateKeyStr))
+	newPlainText, err := utils.DecryptOAEP(ciphertext, privateKey)
+	require.NoError(t, err)
+	assert.Equal(t, newPlainText, plaintext)
+}

+ 44 - 0
core/cipher/encryptors/wechat_pay_pubkey_encryptor.go

@@ -0,0 +1,44 @@
+// Copyright 2024 Tencent Inc. All rights reserved.
+
+package encryptors
+
+import (
+	"context"
+	"crypto/rsa"
+	"fmt"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+// WechatPayPubKeyEncryptor 微信支付字符串加密器,使用微信支付公钥
+type WechatPayPubKeyEncryptor struct {
+	// 微信支付公钥
+	publicKey rsa.PublicKey
+	// 公钥 ID
+	keyID string
+}
+
+// NewWechatPayPubKeyEncryptor 新建一个 WechatPayPubKeyEncryptor
+func NewWechatPayPubKeyEncryptor(keyID string, publicKey rsa.PublicKey) *WechatPayPubKeyEncryptor {
+	return &WechatPayPubKeyEncryptor{publicKey: publicKey, keyID: keyID}
+}
+
+// SelectCertificate 选择合适的微信支付平台证书用于加密
+// 返回公钥对应的 KeyId 作为证书序列号
+func (e *WechatPayPubKeyEncryptor) SelectCertificate(ctx context.Context) (serial string, err error) {
+	return e.keyID, nil
+}
+
+// Encrypt 对字符串加密
+func (e *WechatPayPubKeyEncryptor) Encrypt(ctx context.Context, serial, plaintext string) (ciphertext string, err error) {
+	if serial != e.keyID {
+		return "", fmt.Errorf("serial %v not match key-id %v", serial, e.keyID)
+	}
+
+	// 不需要对空串进行加密
+	if plaintext == "" {
+		return "", nil
+	}
+
+	return utils.EncryptOAEPWithPublicKey(plaintext, &e.publicKey)
+}

+ 507 - 0
core/client.go

@@ -0,0 +1,507 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package core 微信支付 API v3 Go SDK HTTPClient 基础库,你可以使用它来创建一个 Client,并向微信支付发送 HTTP 请求
+//
+// 初始化 Client 时,你需要指定以下参数:
+//   - Credential 用于生成 HTTP Header 中的 Authorization 信息,微信支付 API v3依赖该值来保证请求的真实性和数据的完整性
+//   - Validator 用于对微信支付的应答进行校验,避免被恶意攻击
+package core
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"encoding/xml"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"mime/multipart"
+	"net/http"
+	"net/textproto"
+	"net/url"
+	"os"
+	"reflect"
+	"regexp"
+	"runtime"
+	"strings"
+	"time"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/credentials"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/cipher"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
+)
+
+var (
+	regJSONTypeCheck = regexp.MustCompile(`(?i:(?:application|text)/(?:vnd\.[^;]+\+)?json)`)
+	regXMLTypeCheck  = regexp.MustCompile(`(?i:(?:application|text)/xml)`)
+)
+
+// APIResult 微信支付API v3 请求结果
+type APIResult struct {
+	// 本次请求所使用的 HTTPRequest
+	Request *http.Request
+	// 本次请求所获得的 HTTPResponse
+	Response *http.Response
+}
+
+// ClientOption 微信支付 API v3 HTTPClient core.Client 初始化参数
+type ClientOption interface {
+	// Apply 将初始化参数应用到 DialSettings 中
+	Apply(settings *DialSettings) error
+}
+
+// ErrorOption 错误初始化参数,用于返回错误
+type ErrorOption struct{ Error error }
+
+// Apply 返回初始化错误
+func (w ErrorOption) Apply(*DialSettings) error {
+	return w.Error
+}
+
+// Client 微信支付API v3 基础 Client
+type Client struct {
+	httpClient *http.Client
+	credential auth.Credential
+	validator  auth.Validator
+	signer     auth.Signer
+	cipher     cipher.Cipher
+}
+
+// NewClient 初始化一个微信支付API v3 HTTPClient
+//
+// 初始化的时候你可以传递多个配置信息
+func NewClient(ctx context.Context, opts ...ClientOption) (*Client, error) {
+	settings, err := initSettings(opts)
+	if err != nil {
+		return nil, fmt.Errorf("init client setting err:%v", err)
+	}
+
+	client := initClientWithSettings(ctx, settings)
+	return client, nil
+}
+
+// NewClientWithDialSettings 使用 DialSettings 初始化一个微信支付API v3 HTTPClient
+func NewClientWithDialSettings(ctx context.Context, settings *DialSettings) (*Client, error) {
+	if err := settings.Validate(); err != nil {
+		return nil, err
+	}
+
+	client := initClientWithSettings(ctx, settings)
+	return client, nil
+}
+
+// NewClientWithValidator 使用原 Client 复制一个新的 Client,并设置新 Client 的 validator。
+// 原 Client 不受任何影响
+func NewClientWithValidator(client *Client, validator auth.Validator) *Client {
+	return &Client{
+		httpClient: client.httpClient,
+		credential: client.credential,
+		signer:     client.signer,
+		validator:  validator,
+		cipher:     client.cipher,
+	}
+}
+
+func initClientWithSettings(_ context.Context, settings *DialSettings) *Client {
+	client := &Client{
+		signer:     settings.Signer,
+		validator:  settings.Validator,
+		credential: &credentials.WechatPayCredentials{Signer: settings.Signer},
+		httpClient: settings.HTTPClient,
+		cipher:     settings.Cipher,
+	}
+
+	if client.httpClient == nil {
+		client.httpClient = &http.Client{
+			Timeout: consts.DefaultTimeout,
+		}
+	}
+	return client
+}
+
+func initSettings(opts []ClientOption) (*DialSettings, error) {
+	var (
+		o   DialSettings
+		err error
+	)
+	for _, opt := range opts {
+		if err = opt.Apply(&o); err != nil {
+			return nil, err
+		}
+	}
+	if err := o.Validate(); err != nil {
+		return nil, err
+	}
+	return &o, nil
+}
+
+// Get 向微信支付发送一个 HTTP Get 请求
+func (client *Client) Get(ctx context.Context, requestURL string) (*APIResult, error) {
+	return client.doRequest(ctx, http.MethodGet, requestURL, nil, consts.ApplicationJSON, nil, "")
+}
+
+// Post 向微信支付发送一个 HTTP Post 请求
+func (client *Client) Post(ctx context.Context, requestURL string, requestBody interface{}) (*APIResult, error) {
+	return client.requestWithJSONBody(ctx, http.MethodPost, requestURL, requestBody)
+}
+
+// Patch 向微信支付发送一个 HTTP Patch 请求
+func (client *Client) Patch(ctx context.Context, requestURL string, requestBody interface{}) (*APIResult, error) {
+	return client.requestWithJSONBody(ctx, http.MethodPatch, requestURL, requestBody)
+}
+
+// Put 向微信支付发送一个 HTTP Put 请求
+func (client *Client) Put(ctx context.Context, requestURL string, requestBody interface{}) (*APIResult, error) {
+	return client.requestWithJSONBody(ctx, http.MethodPut, requestURL, requestBody)
+}
+
+// Delete 向微信支付发送一个 HTTP Delete 请求
+func (client *Client) Delete(ctx context.Context, requestURL string, requestBody interface{}) (*APIResult, error) {
+	return client.requestWithJSONBody(ctx, http.MethodDelete, requestURL, requestBody)
+}
+
+// Upload 向微信支付发送上传文件
+// 推荐使用 services/fileuploader 中各上传接口的实现
+func (client *Client) Upload(ctx context.Context, requestURL, meta, reqBody, formContentType string) (
+	*APIResult, error,
+) {
+	return client.doRequest(ctx, http.MethodPost, requestURL, nil, formContentType, strings.NewReader(reqBody), meta)
+}
+
+func (client *Client) requestWithJSONBody(ctx context.Context, method, requestURL string, body interface{}) (
+	*APIResult, error,
+) {
+	reqBody, err := setBody(body, consts.ApplicationJSON)
+	if err != nil {
+		return nil, err
+	}
+
+	return client.doRequest(ctx, method, requestURL, nil, consts.ApplicationJSON, reqBody, reqBody.String())
+}
+
+func (client *Client) doRequest(
+	ctx context.Context,
+	method string,
+	requestURL string,
+	header http.Header,
+	contentType string,
+	reqBody io.Reader,
+	signBody string,
+) (*APIResult, error) {
+
+	var (
+		err           error
+		authorization string
+		request       *http.Request
+	)
+
+	// Construct Request
+	if request, err = http.NewRequestWithContext(ctx, method, requestURL, reqBody); err != nil {
+		return nil, err
+	}
+
+	// Header Setting Priority:
+	// Fixed Headers > Per-Request Header Parameters
+
+	// Add Request Header Parameters
+	for key, values := range header {
+		for _, v := range values {
+			request.Header.Add(key, v)
+		}
+	}
+
+	// Set Fixed Headers
+	request.Header.Set(consts.Accept, "*/*")
+	request.Header.Set(consts.ContentType, contentType)
+
+	ua := fmt.Sprintf(consts.UserAgentFormat, consts.Version, runtime.GOOS, runtime.Version())
+	request.Header.Set(consts.UserAgent, ua)
+
+	// Set Authentication
+	if authorization, err = client.credential.GenerateAuthorizationHeader(
+		ctx, method, request.URL.RequestURI(),
+		signBody,
+	); err != nil {
+		return nil, fmt.Errorf("generate authorization err:%s", err.Error())
+	}
+	request.Header.Set(consts.Authorization, authorization)
+
+	// indicate Wechatpay-Serial that client can verify
+	if serial, err := client.validator.GetAcceptSerial(ctx); err == nil {
+		request.Header.Set(consts.WechatPaySerial, serial)
+	}
+
+	// Send HTTP Request
+	result, err := client.doHTTP(request)
+	if err != nil {
+		return result, err
+	}
+	// Check if Success
+	if err = CheckResponse(result.Response); err != nil {
+		return result, err
+	}
+	// Validate WechatPay Signature
+	if err = client.validator.Validate(ctx, result.Response); err != nil {
+		return result, err
+	}
+	return result, nil
+}
+
+// Request 向微信支付发送请求
+//
+// 相比于 Get / Post / Put / Patch / Delete 方法,本方法可以设置更多内容
+// 特别地,如果需要为当前请求设置 Header,可以使用本方法
+func (client *Client) Request(
+	ctx context.Context,
+	method, requestPath string,
+	headerParams http.Header,
+	queryParams url.Values,
+	postBody interface{},
+	contentType string,
+) (result *APIResult, err error) {
+
+	// Setup path and query parameters
+	varURL, err := url.Parse(requestPath)
+	if err != nil {
+		return nil, err
+	}
+
+	// Adding Query Param
+	query := varURL.Query()
+	for k, values := range queryParams {
+		for _, v := range values {
+			query.Add(k, v)
+		}
+	}
+
+	// Encode the parameters.
+	varURL.RawQuery = query.Encode()
+
+	if postBody == nil {
+		return client.doRequest(ctx, method, varURL.String(), headerParams, contentType, nil, "")
+	}
+
+	// Detect postBody type and set body content
+	if contentType == "" {
+		contentType = consts.ApplicationJSON
+	}
+	var body *bytes.Buffer
+	body, err = setBody(postBody, contentType)
+	if err != nil {
+		return nil, err
+	}
+	return client.doRequest(ctx, method, varURL.String(), headerParams, contentType, body, body.String())
+}
+
+func (client *Client) doHTTP(req *http.Request) (result *APIResult, err error) {
+	result = &APIResult{
+		Request: req,
+	}
+
+	result.Response, err = client.httpClient.Do(req)
+	return result, err
+}
+
+// EncryptRequest 使用 cipher 对请求结构进行原地加密,并返回加密所用的平台证书的序列号。
+// 未设置 cipher 时将跳过加密,并返回空序列号。
+//
+// 本方法会对结构中的敏感字段进行原地加密,因此需要传入结构体的指针。
+func (client *Client) EncryptRequest(ctx context.Context, req interface{}) (string, error) {
+	if client.cipher == nil {
+		return "", nil
+	}
+	return client.cipher.Encrypt(ctx, req)
+}
+
+// DecryptResponse 使用 cipher 对应答结构进行原地解密,未设置 cipher 时将跳过解密
+//
+// 本方法会对结构中的敏感字段进行原地解密,因此需要传入结构体的指针。
+func (client *Client) DecryptResponse(ctx context.Context, resp interface{}) error {
+	if client.cipher == nil {
+		return nil
+	}
+	return client.cipher.Decrypt(ctx, resp)
+}
+
+// Sign 使用 signer 对字符串进行签名
+func (client *Client) Sign(ctx context.Context, message string) (result *auth.SignatureResult, err error) {
+	return client.signer.Sign(ctx, message)
+}
+
+// CheckResponse 校验请求是否成功
+//
+// 当http回包的状态码的范围不是200-299之间的时候,会返回相应的错误信息,主要包括http状态码、回包错误码、回包错误信息提示
+func CheckResponse(resp *http.Response) error {
+	if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
+		return nil
+	}
+	slurp, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return fmt.Errorf("invalid response, read body error: %w", err)
+	}
+	_ = resp.Body.Close()
+
+	resp.Body = ioutil.NopCloser(bytes.NewBuffer(slurp))
+	apiError := &APIError{
+		StatusCode: resp.StatusCode,
+		Header:     resp.Header,
+		Body:       string(slurp),
+	}
+	// 忽略 JSON 解析错误,均返回 apiError
+	_ = json.Unmarshal(slurp, apiError)
+	return apiError
+}
+
+// UnMarshalResponse 将回包组织成结构化数据
+func UnMarshalResponse(httpResp *http.Response, resp interface{}) error {
+	body, err := ioutil.ReadAll(httpResp.Body)
+	_ = httpResp.Body.Close()
+
+	if err != nil {
+		return err
+	}
+
+	httpResp.Body = ioutil.NopCloser(bytes.NewBuffer(body))
+
+	err = json.Unmarshal(body, resp)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// CreateFormField 设置form-data 中的普通属性
+//
+// 示例内容
+//
+//	Content-Disposition: form-data; name="meta";
+//	Content-Type: application/json
+//
+//	{ "filename": "file_test.mp4", "sha256": " hjkahkjsjkfsjk78687dhjahdajhk " }
+//
+// 如果要设置上述内容
+//
+//	CreateFormField(w, "meta", "application/json", meta)
+func CreateFormField(w *multipart.Writer, fieldName, contentType string, fieldValue []byte) error {
+	h := make(textproto.MIMEHeader)
+	h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s";`, fieldName))
+	h.Set("Content-Type", contentType)
+	part, err := w.CreatePart(h)
+	if err != nil {
+		return err
+	}
+	_, err = part.Write(fieldValue)
+	return err
+}
+
+// CreateFormFile 设置form-data中的文件
+//
+// 示例内容:
+//
+//	Content-Disposition: form-data; name="file"; filename="file_test.mp4";
+//	Content-Type: video/mp4
+//
+//	pic1  //pic1即为媒体视频的二进制内容
+//
+// 如果要设置上述内容,则CreateFormFile(w, "file_test.mp4", "video/mp4", pic1)
+func CreateFormFile(w *multipart.Writer, filename, contentType string, file []byte) error {
+	h := make(textproto.MIMEHeader)
+	h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "file", filename))
+	h.Set("Content-Type", contentType)
+	part, err := w.CreatePart(h)
+	if err != nil {
+		return err
+	}
+	_, err = part.Write(file)
+	return err
+}
+
+// setBody Set Request body from an interface
+//
+//revive:disable-next-line:cyclomatic 本函数实现需要考虑多种情况,但理解起来并不复杂,进行圈复杂度豁免
+func setBody(body interface{}, contentType string) (bodyBuf *bytes.Buffer, err error) {
+	bodyBuf = &bytes.Buffer{}
+
+	switch b := body.(type) {
+	case string:
+		_, err = bodyBuf.WriteString(b)
+	case *string:
+		_, err = bodyBuf.WriteString(*b)
+	case []byte:
+		_, err = bodyBuf.Write(b)
+	case **os.File:
+		_, err = bodyBuf.ReadFrom(*b)
+	case io.Reader:
+		_, err = bodyBuf.ReadFrom(b)
+	default:
+		if regJSONTypeCheck.MatchString(contentType) {
+			err = json.NewEncoder(bodyBuf).Encode(body)
+		} else if regXMLTypeCheck.MatchString(contentType) {
+			err = xml.NewEncoder(bodyBuf).Encode(body)
+		}
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	if bodyBuf.Len() == 0 {
+		err = fmt.Errorf("invalid body type %s", contentType)
+		return nil, err
+	}
+	return bodyBuf, nil
+}
+
+// contains is a case-insensitive match, finding needle in a haystack
+func contains(haystack []string, needle string) bool {
+	for _, a := range haystack {
+		if strings.EqualFold(a, needle) {
+			return true
+		}
+	}
+	return false
+}
+
+// SelectHeaderContentType select a content type from the available list.
+func SelectHeaderContentType(contentTypes []string) string {
+	if len(contentTypes) == 0 {
+		return consts.ApplicationJSON
+	}
+	if contains(contentTypes, consts.ApplicationJSON) {
+		return consts.ApplicationJSON
+	}
+	return contentTypes[0] // use the first content type specified in 'consumes'
+}
+
+// ParameterToString 将参数转换为字符串,并使用指定分隔符分隔列表参数
+func ParameterToString(obj interface{}, collectionFormat string) string {
+	var delimiter string
+
+	switch collectionFormat {
+	case "pipes":
+		delimiter = "|"
+	case "ssv":
+		delimiter = " "
+	case "tsv":
+		delimiter = "\t"
+	case "csv":
+		delimiter = ","
+	}
+
+	if reflect.TypeOf(obj).Kind() == reflect.Slice {
+		return strings.Trim(strings.Replace(fmt.Sprint(obj), " ", delimiter, -1), "[]")
+	} else if t, ok := obj.(time.Time); ok {
+		return t.Format(time.RFC3339)
+	}
+
+	return fmt.Sprintf("%v", obj)
+}
+
+// ParameterToJSON 将参数转换为 Json 字符串
+func ParameterToJSON(obj interface{}) (string, error) {
+	jsonBuf, err := json.Marshal(obj)
+	if err != nil {
+		return "", err
+	}
+	return string(jsonBuf), err
+}

+ 125 - 0
core/client_example_test.go

@@ -0,0 +1,125 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package core_test
+
+import (
+	"context"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/json"
+	"log"
+	"mime/multipart"
+	"net/http"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/cipher"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/option"
+)
+
+func ExampleNewClient_default() {
+	// 示例参数,实际使用时请自行初始化
+	var (
+		mchID                      string
+		mchCertificateSerialNumber string
+		mchPrivateKey              *rsa.PrivateKey
+		wechatPayCertificateList   []*x509.Certificate
+		customHTTPClient           *http.Client
+	)
+
+	client, err := core.NewClient(
+		context.Background(),
+		// 一次性设置 签名/验签/敏感字段加解密,并注册 平台证书下载器,自动定时获取最新的平台证书
+		option.WithWechatPayAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, wechatPayCertificateList),
+		// 设置自定义 HTTPClient 实例,不设置时默认使用 http.Client{},并设置超时时间为 30s
+		option.WithHTTPClient(customHTTPClient),
+	)
+	if err != nil {
+		log.Printf("new wechat pay client err:%s", err.Error())
+		return
+	}
+	// 接下来使用 client 进行请求发送
+	_ = client
+}
+
+func ExampleNewClient_auto_update_certificate() {
+	// 示例参数,实际使用时请自行初始化
+	var (
+		mchID                      string
+		mchCertificateSerialNumber string
+		mchPrivateKey              *rsa.PrivateKey
+		mchAPIv3Key                string
+	)
+
+	client, err := core.NewClient(
+		context.Background(),
+		// 一次性设置 签名/验签/敏感字段加解密,并注册 平台证书下载器,自动定时获取最新的平台证书
+		option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
+	)
+	if err != nil {
+		log.Printf("new wechat pay client err:%s", err.Error())
+		return
+	}
+	// 接下来使用 client 进行请求发送
+	_ = client
+}
+
+func ExampleNewClient_fully_customized() {
+	var (
+		signer           auth.Signer      // 自定义实现 auth.Signer 接口的实例
+		verifier         auth.Verifier    // 自定义实现 auth.Verifier 接口的实例
+		encryptor        cipher.Encryptor // 自定义实现 auth.Encryptor 接口的实例
+		decryptor        cipher.Decryptor // 自定义实现 cipher.Decryptor 接口的实例
+		customHTTPClient *http.Client     // 自定义 HTTPClient
+	)
+
+	client, err := core.NewClient(
+		context.Background(),
+		// 使用自定义 Signer 初始化 微信支付签名器
+		option.WithSigner(signer),
+		// 使用自定义 Verifier 初始化 微信支付应答验证器
+		option.WithVerifier(verifier),
+		// 使用自定义 Encryptor/Decryptor 初始化 微信支付敏感字段加解密器
+		option.WithWechatPayCipher(encryptor, decryptor),
+		// 使用自定义 HTTPClient
+		option.WithHTTPClient(customHTTPClient),
+	)
+	if err != nil {
+		log.Printf("new wechat pay client err:%s", err.Error())
+		return
+	}
+	// 接下来使用 client 进行请求发送
+	_ = client
+}
+
+func ExampleCreateFormField() {
+	var w multipart.Writer
+
+	meta := map[string]string{
+		"filename": "sample.jpg",
+		"sha256":   "5944758444f0af3bc843e39b611a6b0c8c38cca44af653cd461b5765b71dc3f8",
+	}
+
+	metaBytes, err := json.Marshal(meta)
+	if err != nil {
+		// TODO: 处理错误
+		return
+	}
+
+	err = core.CreateFormField(&w, "meta", consts.ApplicationJSON, metaBytes)
+	if err != nil {
+		// TODO: 处理错误
+	}
+}
+
+func ExampleCreateFormFile() {
+	var w multipart.Writer
+
+	var fileContent []byte
+
+	err := core.CreateFormFile(&w, "sample.jpg", consts.ImageJPG, fileContent)
+	if err != nil {
+		// TODO: 处理错误
+	}
+}

+ 449 - 0
core/client_test.go

@@ -0,0 +1,449 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package core_test
+
+import (
+	"bytes"
+	"context"
+	"crypto"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"math/rand"
+	"mime/multipart"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/signers"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/option"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+const (
+	testMchID                   = "example-mchid"
+	testCertificateSerialNumber = "example-sn"
+
+	// NOTE: 以下是随机生成的测试密钥,请勿用于生产环境
+	testPrivateKey = "-----BEGIN TESTING KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkxOav8p5RFFmN\n7hjLGrNtXPYgCd0Zuvxabv+IWl1HVkWi/1iVqac+XwKH/ZeCYDURqZ6P0iiq8NBd\npygoeJiQM+qzaTV3alNXdLcpcaQSNqJ16a7Z2Co5LgOkBJHIF1qUWf+BpAtyLqo5\niGUQ47w3IWQHtfpW7RrECiNsI7kGKuSc4U2JX/gxrFG7ugpRA9Gp1eF0/wBMWSKV\nmKveKERvAueaTKEujpN4lctoO1wsMW93nuFNH1gtHPYmkaZaJS88GEp0VYJIcOpX\n2HlVPPiWx+0KAndcqMLVQ+qTk21tivpjuqPDstxcT9cXn/3CzSEkYrKkMLpjpSl/\nIn+amHddAgMBAAECggEADtQRlsAU82MLdDR7UrwCbdMx60w387raPyFCKflH78WZ\n2sN0K3PrMzfFuItf+UHDROWo+XSGaGvntKX4fTvtLv0dICxVvXt6KKK+YSJzC5iT\nIl13eO91TVQQy9AFdqZzZmp7DiW/SfVdKHRX9B8qryN4JyF/eBc6k23+JhtI6X8J\nrPeXGcw/Muo2cvoZ6oarRvzKU7pDivZADavAsgGwi+QwZUtrpUhDbJxWxoasCrWz\n6X24D+JbKndf/AeyHC2mqoAwTYdQgkkPHLJWGsqAt/GHtHLmZHIPc0dXfksP5omX\nOIii34YNud1j2/X0ryxoRBKPGIolV5Tyyh9PX75UAQKBgQDa/oPFUNsBo+NkfE4r\nMry+mnAeBM06vZh65acbHu3prqzn7tzfQR5rF9nIEUjNkach+ZeTnUxKfX4TwfiL\nibbM1Epb+yhEpSlA9HhY1AGhdNKpFbq63oVzS+lwpmLcXFLHhOYw8GrnYH9lcE+E\nYCqFcuk4t/y3rU/8Y5GhZKZ53QKBgQDAnKk6CEgnSkXfZLmzVtoM/5hnI0GEDOUo\nV35alqvgdJtCiPs4C03snYLVHqHjAknGzLGONAQ6h9au2qwcHy1qH/noq151u92b\nbCrKmghnm2SoIgCaZ7i2scWm6NM9Da60H662WxjaKcZMnUClm+G+Irl9m5cm1i3V\n56ZU63nbgQKBgHyAlFO6mzg8f4via+J9TvciADngyvjpT2YXaECv/dyL9TtK/oFi\nmTOTdLocsYJFm3piVv2SQQxcejArZ+2U1rtuufO/P25/Y4vNMRp3NZIgQ5/jfay9\n06rv7oCf57aWOm26LdCG7pAquWLnTh3ZOnNyGAup9mBKhR3dUa8q9MZ1AoGATZGJ\n0VYugKw3sXymEKRkkiGJJdgb9WsgCnwZ5a+SLoWnVUdHLM3YpvbUDrIUbhCo14ft\n5Z/rKAs2mRp1f6nKp1eTVHFXTEDJQWNxZEBeLCN3iQKQjZ5B1EmJmOtgztCoz9+G\ng+fx/UIfmxElTMyXP/RKEVzMpZZRxThSUxa174ECgYEAsviAmgBskM2ibtrA8tNW\njHdgut0xAtIJIIHlYmtksWgAWD4cPtCg7HPurXqBKxMogH/ZsZc6/5PpOIRYBNl0\nEebH0MZ/yiEOrmgsFJ1gKWk3fx8/yLQBlhn32AIhj7wmcFwzi/4hcwihHRCjS7t5\nQhVpKswxQyxqeIdQw0CgKZY=\n-----END TESTING KEY-----"
+
+	testWechatPrivateKeyStr  = "-----BEGIN TESTING KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDzbMaiMbCzJ11sZ2r7/XisolGu1pVpWvnv1PVOAWZZAWr/WcNNuLni8ddJkIs8NdTz+iAHtb9XMNG4hj7d10cy8QE6QG8YUef1fGb47Wee3DjJRk8N9lWyPDAy9AW70yYWItl/05XgkGt2eJuoU5CcZ+Cy0u2nAXxEGs2Z4Fg/100Ylcyq4GrimjngIyUFLnowOcZltJUQSw/Iu63V0BEh9PMNnXhKkwS2xfkA2nZRzSgzpMvX74v8F7Zf+HkyrHHzwY/7YUWg3pSj3GD0xKsJOwzz0MLlS8uIdLj1lKGzzt1ROgwe0sM5LL5XMfmbjDhcVBmyxQI80WiNaC281tVhAgMBAAECggEBAJ134Wrs0Ayky2ej4u5OAvFSM5rxj0fPJV3DGkiy2R18sFWtII03kXBA1+7rxVZW0IJfbLbwGG3z08cVeLeTWqiWhR/ErNlDqtT/+7DOCrkWZtm1VNCIaNla3Ccp+keNiNbLBn4NRqg1ZH8H+FHEdQjonc+waTIe4N9Bo30GRrBMeMbAgN8mwQhZ+6R23j3GsrJOViFpPRgGhih4aEAORxU+DWl22vklxO8lnDzSwfnXvNDvapnPaA8VXekkThxABq3p/ggv5MI1QPYl8BU5PWd6AJlPs/u2nzFGmCgVufHMjdszWYd3hbj5EmhlL1VMs4uxhCC8OM+ypbnx0CmBB5ECgYEA+W3KJciw4qG6XqWgjK9hkgiZrO8z3tu6tQ6ge28f3BxYEbGUab8bKKzacbYODRiRCM5oKcrXfTqP6IbqUoESiuoz0CUPbShp7k00wXKd7BH8LoIECDbctz77NG0KBE31OGEJw4hm762M14V9nU0KDHGuud1CH1fqivTGG0g2HiUCgYEA+dZ+e5iSvin+omXkr3Vhwf3kutX+GNkKm5LrWZNmWybOKw/K1YFvESpfl1b6YgjA/qUXj0tbOumFQw6e/OLfxIvK/dtkd+7pbSC4T9w8rH7zgjYdJ030Nyv3UGfHDbES+z9MDMo5h+3RKsLN2bp1JcXp6vht9CiXDYm1df3O340CgYB118Qg08+WU1iM7O2MajPL3dpVFPJJwUBV2GJDzv2bbZzCR0baKxr2vau6+4tp7ohfQ718uUPT+34QGuXMMwUCsqHmHgxKw0RA/SMGnlM0PE8L3gtvohPnU481dqq72+UWTOpjAie35yPak0wErGgp9u/ZCkr6Kfw6yGhsbVJ8LQKBgEBLxS1FrK4n3JIqqtnE2a21C4JRxBzc7m/vNYZN+s+GgxRt8gNUViMSxpsKFVHZcuGV1yRXflkA8/y37I6kTHYmi80dAxQidgxRmV1kDnFOEpj2GDafRzRTqkgVDRMm+P2T4pyABqJGv8fDbnqUE8Xu0y5XVOS69XTUddCxyuWZAoGAbpF6JOh6B7OV4XRTDm98Z8OPYmYd9JQ4xt8bqsG9LdzvhU/PI4zwIaKDqZ8vzCI+r8TOrC6SBEfjAe6o2FEExmFWTjBAVCp+Qvnz+Pj7d+WP3kCX/B62IZckVhdV3a2frMTBPvAh8XdENdvsu4DsWJBCA54GLU3wdUa/FO0RUsA=\n-----END TESTING KEY-----\n"
+	testWechatCertificateStr = "-----BEGIN CERTIFICATE-----\nMIID1TCCAr2gAwIBAgIRAJ8qZJYAQUwUheimQ8sQNZMwDQYJKoZIhvcNAQELBQAw\nXjELMAkGA1UEBhMCQ04xDjAMBgNVBAoTBU15U1NMMSswKQYDVQQLEyJNeVNTTCBU\nZXN0IFJTQSAtIEZvciB0ZXN0IHVzZSBvbmx5MRIwEAYDVQQDEwlNeVNTTC5jb20w\nHhcNMjEwNjI3MTMwNTM2WhcNMjIwNjI3MTMwNTM2WjAkMQswCQYDVQQGEwJDTjEV\nMBMGA1UEAxMMd2VjaGF0cGF5LWdvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEA82zGojGwsyddbGdq+/14rKJRrtaVaVr579T1TgFmWQFq/1nDTbi54vHX\nSZCLPDXU8/ogB7W/VzDRuIY+3ddHMvEBOkBvGFHn9Xxm+O1nntw4yUZPDfZVsjww\nMvQFu9MmFiLZf9OV4JBrdnibqFOQnGfgstLtpwF8RBrNmeBYP9dNGJXMquBq4po5\n4CMlBS56MDnGZbSVEEsPyLut1dARIfTzDZ14SpMEtsX5ANp2Uc0oM6TL1++L/Be2\nX/h5Mqxx88GP+2FFoN6Uo9xg9MSrCTsM89DC5UvLiHS49ZShs87dUToMHtLDOSy+\nVzH5m4w4XFQZssUCPNFojWgtvNbVYQIDAQABo4HHMIHEMA4GA1UdDwEB/wQEAwIH\ngDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSMEGDAWgBQogSYF0TQaP8FzD7uT\nzxUcPwO/fzBjBggrBgEFBQcBAQRXMFUwIQYIKwYBBQUHMAGGFWh0dHA6Ly9vY3Nw\nLm15c3NsLmNvbTAwBggrBgEFBQcwAoYkaHR0cDovL2NhLm15c3NsLmNvbS9teXNz\nbHRlc3Ryc2EuY3J0MBcGA1UdEQQQMA6CDHdlY2hhdHBheS1nbzANBgkqhkiG9w0B\nAQsFAAOCAQEAjh4oxMcJqsVaN5/aA+4+NSfV9wR4uzTVtAyL/dApymZn6Wjknd85\nDltekcTflNP84bDiFEE3Ls3RYatjRx9pWeW7QbpdYvfDtWuxL5dhzRYtUO83z8wT\n+/sceeyNOQAWGD6Gt7Aw7yb7bIZ5slcZYepqdKSHyMnn06CCNRZtDVTuQYRqnmoh\nCaK5RNe4lYM/hncMgddE/DugTxzh5NUMpAY4xAsqOofkVmptX3trVZVILPglJ6nQ\n5dALCpp2UCuxikwFdEpvvGIC2qZQv5jmemFLCDIQZ227GUZ/EcbuTtAdQYHUnGJT\nvrFBSDe4FbKwljUmrccD/LkR9FmPn6gWKA==\n-----END CERTIFICATE-----\n"
+
+	fileName = "picture.jpeg"
+
+	responseBody   = `{"hello":"client"}`
+	testRequestUri = "/v3/resource?first=this+is+a+field&second=was+it+clear+%28already%29%3F"
+)
+
+var (
+	privateKey           *rsa.PrivateKey
+	wechatPayPrivateKey  *rsa.PrivateKey
+	wechatPayCertificate *x509.Certificate
+	signer               auth.Signer
+	verifier             auth.Verifier
+	ctx                  context.Context
+)
+
+type signParameter map[string]string
+
+func init() {
+	ctx = context.Background()
+
+	var err error
+	privateKey, err = utils.LoadPrivateKey(testingKey(testPrivateKey))
+	if err != nil {
+		panic(fmt.Errorf("load merchant testing key err:%s", err.Error()))
+	}
+	wechatPayCertificate, err = utils.LoadCertificate(testWechatCertificateStr)
+	if err != nil {
+		panic(fmt.Errorf("generate wechatpay testing certificate err:%s", err.Error()))
+	}
+	wechatPayPrivateKey, err = utils.LoadPrivateKey(testingKey(testWechatPrivateKeyStr))
+	if err != nil {
+		panic(fmt.Errorf("generate wechatpay testing key err:%s", err.Error()))
+	}
+}
+
+func writeResponse(w http.ResponseWriter) {
+	writeSignature(w, responseBody)
+	w.WriteHeader(http.StatusOK)
+	fmt.Fprint(w, responseBody)
+}
+
+func writeSignature(w http.ResponseWriter, body string) {
+	w.Header().Set("Request-Id", "0")
+	w.Header().Set("Wechatpay-Serial", utils.GetCertificateSerialNumber(*wechatPayCertificate))
+
+	nonce := "this-is-a-nonce"
+	w.Header().Set("Wechatpay-Nonce", nonce)
+
+	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
+	w.Header().Set("Wechatpay-Timestamp", timestamp)
+
+	signature, _ := utils.SignSHA256WithRSA(
+		fmt.Sprintf("%s\n%s\n%s\n", timestamp, nonce, body), wechatPayPrivateKey)
+	w.Header().Set("Wechatpay-Signature", signature)
+}
+
+func parseAuthorization(t *testing.T, authorization string) (schema string, param signParameter) {
+	m := make(signParameter)
+
+	s1 := strings.Split(authorization, " ")
+	assert.Equal(t, len(s1), 2)
+
+	s2 := strings.Split(s1[1], ",")
+	assert.Equal(t, len(s2), 5)
+
+	for _, v := range s2 {
+		s3 := strings.SplitN(v, "=", 2)
+		assert.Equal(t, len(s3), 2)
+		pk := s3[0]
+		pv := strings.Trim(s3[1], "\"")
+		m[pk] = pv
+	}
+
+	return s1[0], m
+}
+
+func assertAuthorization(t *testing.T, schema, method, uri string, params signParameter, body []byte) {
+	assert.Equal(t, schema, "WECHATPAY2-SHA256-RSA2048")
+	assert.Equal(t, params["mchid"], testMchID)
+	assert.Equal(t, params["serial_no"], testCertificateSerialNumber)
+
+	message := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n",
+		method,
+		uri,
+		params["timestamp"],
+		params["nonce_str"],
+		body)
+	hashed := sha256.Sum256([]byte(message))
+	signBytes, err := base64.StdEncoding.DecodeString(params["signature"])
+	assert.NoError(t, err)
+	assert.NoError(t, rsa.VerifyPKCS1v15(&privateKey.PublicKey, crypto.SHA256, hashed[:], signBytes))
+}
+
+func TestGet(t *testing.T) {
+	opts := []core.ClientOption{
+		option.WithMerchantCredential(testMchID, testCertificateSerialNumber, privateKey),
+		option.WithWechatPayCertificate([]*x509.Certificate{wechatPayCertificate}),
+	}
+	client, err := core.NewClient(ctx, opts...)
+	require.NoError(t, err)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, r.Method, "GET")
+		assert.Equal(t, r.RequestURI, testRequestUri)
+
+		schema, params := parseAuthorization(t, r.Header.Get("Authorization"))
+		assertAuthorization(t, schema, r.Method, r.RequestURI, params, make([]byte, 0))
+
+		writeResponse(w)
+	}))
+	defer ts.Close()
+
+	result, err := client.Get(ctx, ts.URL+testRequestUri)
+	assert.NoError(t, err)
+	body, err := ioutil.ReadAll(result.Response.Body)
+	assert.NoError(t, err)
+	assert.Equal(t, string(body), responseBody)
+}
+
+type testData struct {
+	StockID           string `json:"stock_id"`
+	StockCreatorMchID string `json:"stock_creator_mchid"`
+	OutRequestNo      string `json:"out_request_no"`
+	AppID             string `json:"appid"`
+}
+
+func TestPost(t *testing.T) {
+	opts := []core.ClientOption{
+		option.WithMerchantCredential(testMchID, testCertificateSerialNumber, privateKey),
+		option.WithWechatPayCertificate([]*x509.Certificate{wechatPayCertificate}),
+	}
+	client, err := core.NewClient(ctx, opts...)
+	require.NoError(t, err)
+	data := &testData{
+		StockID:           "xxx",
+		StockCreatorMchID: "xxx",
+		OutRequestNo:      "xxx",
+		AppID:             "xxx",
+	}
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, r.Method, "POST")
+		assert.Equal(t, r.RequestURI, testRequestUri)
+
+		schema, params := parseAuthorization(t, r.Header.Get("Authorization"))
+		body, _ := ioutil.ReadAll(r.Body)
+		assertAuthorization(t, schema, r.Method, r.RequestURI, params, body)
+
+		writeResponse(w)
+	}))
+	defer ts.Close()
+
+	result, err := client.Post(ctx, ts.URL+testRequestUri, data)
+	assert.NoError(t, err)
+	body, err := ioutil.ReadAll(result.Response.Body)
+	assert.NoError(t, err)
+	assert.Equal(t, string(body), responseBody)
+}
+
+func TestRequest(t *testing.T) {
+	opts := []core.ClientOption{
+		option.WithMerchantCredential(testMchID, testCertificateSerialNumber, privateKey),
+		option.WithWechatPayCertificate([]*x509.Certificate{wechatPayCertificate}),
+	}
+	client, err := core.NewClient(ctx, opts...)
+	require.NoError(t, err)
+
+	data := &testData{
+		StockID:           "xxx",
+		StockCreatorMchID: "xxx",
+		OutRequestNo:      "xxx",
+		AppID:             "xxx",
+	}
+
+	tt := []struct {
+		method      string
+		uri         string
+		contentType string
+		body        interface{}
+		header      http.Header
+	}{
+		{
+			http.MethodGet,
+			"/v3/get",
+			"",
+			nil,
+			http.Header{"My-Id": {"1234"}},
+		},
+		{
+			http.MethodPost,
+			testRequestUri,
+			"application/json",
+			data,
+			http.Header{"My-Id": {"1234"}},
+		},
+		{
+			http.MethodDelete,
+			"/v3/delete",
+			"",
+			nil,
+			nil,
+		},
+		{
+			http.MethodPut,
+			"/v3/put",
+			"",
+			data,
+			http.Header{"My-Id": {"1234"}},
+		},
+		{
+			http.MethodPatch,
+			"/v3/patch",
+			"",
+			data,
+			nil,
+		},
+	}
+
+	for _, test := range tt {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			assert.Equal(t, test.method, r.Method)
+			assert.Equal(t, test.uri, r.RequestURI)
+			if test.header != nil {
+				assert.Equal(t, "1234", r.Header.Get("My-Id"))
+			}
+
+			schema, params := parseAuthorization(t, r.Header.Get("Authorization"))
+			body, _ := ioutil.ReadAll(r.Body)
+			assertAuthorization(t, schema, r.Method, r.RequestURI, params, body)
+			assert.Equal(t, "9F2A649600414C1485E8A643CB103593", r.Header.Get("Wechatpay-Serial"))
+
+			if test.body != nil {
+				assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
+
+				var req testData
+				err = json.Unmarshal(body, &req)
+				assert.NoError(t, err)
+				assert.Equal(t, data, &req)
+			}
+
+			writeResponse(w)
+		}))
+
+		testUrl, err := url.Parse(ts.URL + test.uri)
+		assert.NoError(t, err)
+
+		result, err := client.Request(
+			ctx,
+			test.method,
+			ts.URL+testUrl.Path,
+			test.header,
+			testUrl.Query(),
+			test.body,
+			test.contentType,
+		)
+		assert.NoError(t, err)
+		body, err := ioutil.ReadAll(result.Response.Body)
+		assert.NoError(t, err)
+		assert.Equal(t, http.StatusOK, result.Response.StatusCode)
+		assert.Equal(t, responseBody, string(body))
+		ts.Close()
+	}
+}
+
+func TestClientVerifyFail(t *testing.T) {
+	opts := []core.ClientOption{
+		option.WithMerchantCredential(testMchID, testCertificateSerialNumber, privateKey),
+		option.WithWechatPayCertificate([]*x509.Certificate{wechatPayCertificate}),
+	}
+	client, err := core.NewClient(ctx, opts...)
+	require.NoError(t, err)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Request-Id", "0")
+		w.Header().Set("Wechatpay-Serial", utils.GetCertificateSerialNumber(*wechatPayCertificate))
+
+		nonce := "this-is-a-nonce"
+		w.Header().Set("Wechatpay-Nonce", nonce)
+
+		timestamp := strconv.FormatInt(time.Now().Unix(), 10)
+		w.Header().Set("Wechatpay-Timestamp", timestamp)
+
+		w.Header().Set("Wechatpay-Signature", "AABB")
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer ts.Close()
+
+	_, err = client.Get(ctx, ts.URL+testRequestUri)
+	assert.Contains(t, err.Error(), "verify fail")
+}
+
+func TestClientNoAuth(t *testing.T) {
+	opts := []core.ClientOption{
+		option.WithMerchantCredential(testMchID, testCertificateSerialNumber, privateKey),
+		option.WithWechatPayCertificate([]*x509.Certificate{wechatPayCertificate}),
+	}
+	client, err := core.NewClient(ctx, opts...)
+	require.NoError(t, err)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(401)
+		w.Header().Set("Request-Id", "0")
+		fmt.Fprint(w, `{"code":"SIGN_ERROR","message":"sign error"}`)
+	}))
+	defer ts.Close()
+
+	_, err = client.Get(ctx, ts.URL+testRequestUri)
+	apiError, ok := err.(*core.APIError)
+	assert.True(t, ok)
+	assert.Equal(t, 401, apiError.StatusCode)
+	assert.Equal(t, "SIGN_ERROR", apiError.Code)
+	assert.Equal(t, "sign error", apiError.Message)
+}
+
+type meta struct {
+	FileName string `json:"filename" binding:"required"` // 商户上传的媒体图片的名称,商户自定义,必须以JPG、BMP、PNG为后缀。
+	Sha256   string `json:"sha256" binding:"required"`   // 图片文件的文件摘要,即对图片文件的二进制内容进行sha256计算得到的值。
+}
+
+func TestClient_Upload(t *testing.T) {
+	// 如果你有自定义的Signer或者Verifier
+	signer = &signers.SHA256WithRSASigner{
+		MchID:               testMchID,
+		PrivateKey:          privateKey,
+		CertificateSerialNo: testCertificateSerialNumber,
+	}
+
+	verifier = verifiers.NewSHA256WithRSAVerifier(
+		core.NewCertificateMap(
+			map[string]*x509.Certificate{utils.GetCertificateSerialNumber(*wechatPayCertificate): wechatPayCertificate},
+		),
+	)
+
+	client, err := core.NewClient(ctx, option.WithSigner(signer), option.WithVerifier(verifier))
+	require.NoError(t, err)
+	pictureBytes := make([]byte, 1024)
+	// 随机的数据充当图片数据
+	rand.Read(pictureBytes)
+	// 计算文件序列化后的sha256
+	h := sha256.New()
+	_, err = h.Write(pictureBytes)
+	assert.NoError(t, err)
+	metaObject := &meta{}
+	pictureSha256 := h.Sum(nil)
+	metaObject.FileName = fileName
+	metaObject.Sha256 = fmt.Sprintf("%x", string(pictureSha256))
+	metaByte, _ := json.Marshal(metaObject)
+	reqBody := &bytes.Buffer{}
+	writer := multipart.NewWriter(reqBody)
+	err = core.CreateFormField(writer, "meta", "application/json", metaByte)
+	assert.NoError(t, err)
+	err = core.CreateFormFile(writer, fileName, "image/jpg", pictureBytes)
+	assert.NoError(t, err)
+	err = writer.Close()
+	assert.NoError(t, err)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, r.Method, "POST")
+		assert.Equal(t, r.RequestURI, testRequestUri)
+
+		mr, err := r.MultipartReader()
+		assert.NoError(t, err)
+
+		var body []byte
+		for {
+			p, err := mr.NextPart()
+			if err == io.EOF {
+				break
+			}
+			assert.NoError(t, err)
+			if p.FormName() == "meta" {
+				body, _ = ioutil.ReadAll(p)
+				assert.Equal(t, metaByte, body)
+			} else if p.FormName() == "file" {
+				slurp, _ := ioutil.ReadAll(p)
+				assert.Equal(t, pictureBytes, slurp)
+			}
+		}
+
+		schema, params := parseAuthorization(t, r.Header.Get("Authorization"))
+		assertAuthorization(t, schema, r.Method, r.RequestURI, params, body)
+
+		writeResponse(w)
+	}))
+	defer ts.Close()
+
+	result, err := client.Upload(
+		ctx,
+		ts.URL+testRequestUri,
+		string(metaByte),
+		reqBody.String(),
+		writer.FormDataContentType())
+	assert.NoError(t, err)
+	if result.Response.Body != nil {
+		defer result.Response.Body.Close()
+	}
+	body, err := ioutil.ReadAll(result.Response.Body)
+	assert.NoError(t, err)
+	t.Log(string(body))
+	assert.Equal(t, string(body), responseBody)
+}
+
+func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") }

+ 57 - 0
core/consts/const.go

@@ -0,0 +1,57 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package consts 微信支付 API v3 Go SDK 常量
+package consts
+
+import "time"
+
+// 微信支付 API 地址
+const (
+	WechatPayAPIServer       = "https://apihk.mch.weixin.qq.com" // 微信支付 API 地址
+	WechatPayAPIServerBackup = "https://api2.mch.weixin.qq.com"  // 微信支付 API 备份地址
+)
+
+// SDK 相关信息
+const (
+	Version         = "0.2.20"                     // SDK 版本
+	UserAgentFormat = "WechatPay-Go/%s (%s) GO/%s" // UserAgent中的信息
+)
+
+// HTTP 请求报文 Header 相关常量
+const (
+	Authorization = "Authorization"  // Header 中的 Authorization 字段
+	Accept        = "Accept"         // Header 中的 Accept 字段
+	ContentType   = "Content-Type"   // Header 中的 ContentType 字段
+	ContentLength = "Content-Length" // Header 中的 ContentLength 字段
+	UserAgent     = "User-Agent"     // Header 中的 UserAgent 字段
+)
+
+// 常用 ContentType
+const (
+	ApplicationJSON = "application/json"
+	ImageJPG        = "image/jpg"
+	ImagePNG        = "image/png"
+	VideoMP4        = "video/mp4"
+)
+
+// 请求报文签名相关常量
+const (
+	SignatureMessageFormat = "%s\n%s\n%d\n%s\n%s\n" // 数字签名原文格式
+	// HeaderAuthorizationFormat 请求头中的 Authorization 拼接格式
+	HeaderAuthorizationFormat = "%s mchid=\"%s\",nonce_str=\"%s\",timestamp=\"%d\",serial_no=\"%s\",signature=\"%s\""
+)
+
+// HTTP 应答报文 Header 相关常量
+const (
+	WechatPayTimestamp = "Wechatpay-Timestamp" // 微信支付回包时间戳
+	WechatPayNonce     = "Wechatpay-Nonce"     // 微信支付回包随机字符串
+	WechatPaySignature = "Wechatpay-Signature" // 微信支付回包签名信息
+	WechatPaySerial    = "Wechatpay-Serial"    // 微信支付回包平台序列号
+	RequestID          = "Request-Id"          // 微信支付回包请求ID
+)
+
+// 时间相关常量
+const (
+	FiveMinute     = 5 * 60           // 回包校验最长时间(秒)
+	DefaultTimeout = 30 * time.Second // HTTP 请求默认超时时间
+)

+ 210 - 0
core/downloader/downloader.go

@@ -0,0 +1,210 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package downloader
+
+import (
+	"context"
+	"crypto/rsa"
+	"crypto/x509"
+	"fmt"
+	"sync"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/signers"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/validators"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+// isSameCertificateMap Check if two CertificateMaps stores same certificates.
+// Normally, checking serial number set is enough.
+func isSameCertificateMap(l, r map[string]*x509.Certificate) bool {
+	if l == nil && r == nil {
+		return true
+	}
+
+	if len(l) != len(r) {
+		return false
+	}
+
+	for serialNumber := range l {
+		if _, ok := r[serialNumber]; !ok {
+			return false
+		}
+	}
+
+	return true
+}
+
+// CertificateDownloader 平台证书下载器,下载完成后可直接获取 x509.Certificate 对象或导出证书内容
+type CertificateDownloader struct {
+	certContents map[string]string   // 证书文本内容,用于导出
+	certificates core.CertificateMap // 证书实例
+	client       *core.Client        // 微信支付 API v3 Go SDK HTTPClient
+	mchAPIv3Key  string              // 商户APIv3密钥
+	lock         sync.RWMutex
+}
+
+// Get 获取证书序列号对应的平台证书
+func (d *CertificateDownloader) Get(ctx context.Context, serialNumber string) (*x509.Certificate, bool) {
+	d.lock.RLock()
+	defer d.lock.RUnlock()
+
+	return d.certificates.Get(ctx, serialNumber)
+}
+
+// GetAll 获取平台证书Map
+func (d *CertificateDownloader) GetAll(ctx context.Context) map[string]*x509.Certificate {
+	d.lock.RLock()
+	defer d.lock.RUnlock()
+
+	return d.certificates.GetAll(ctx)
+}
+
+// GetNewestSerial 获取最新的平台证书的证书序列号
+func (d *CertificateDownloader) GetNewestSerial(ctx context.Context) string {
+	d.lock.RLock()
+	defer d.lock.RUnlock()
+
+	return d.certificates.GetNewestSerial(ctx)
+}
+
+// Export 获取证书序列号对应的平台证书内容
+func (d *CertificateDownloader) Export(_ context.Context, serialNumber string) (string, bool) {
+	d.lock.RLock()
+	defer d.lock.RUnlock()
+
+	content, ok := d.certContents[serialNumber]
+	return content, ok
+}
+
+// ExportAll 获取平台证书内容Map
+func (d *CertificateDownloader) ExportAll(_ context.Context) map[string]string {
+	d.lock.RLock()
+	defer d.lock.RUnlock()
+
+	ret := make(map[string]string)
+	for serialNumber, content := range d.certContents {
+		ret[serialNumber] = content
+	}
+
+	return ret
+}
+
+func (d *CertificateDownloader) decryptCertificate(
+	_ context.Context, encryptCertificate *encryptCertificate,
+) (string, error) {
+	plaintext, err := utils.DecryptAES256GCM(
+		d.mchAPIv3Key, *encryptCertificate.AssociatedData,
+		*encryptCertificate.Nonce, *encryptCertificate.Ciphertext,
+	)
+	if err != nil {
+		return "", fmt.Errorf("decrypt downloaded certificate failed: %v", err)
+	}
+
+	return plaintext, nil
+}
+
+func (d *CertificateDownloader) updateCertificates(
+	ctx context.Context, certContents map[string]string, certificates map[string]*x509.Certificate,
+) {
+	d.lock.Lock()
+	defer d.lock.Unlock()
+	if isSameCertificateMap(d.certificates.GetAll(ctx), certificates) {
+		return
+	}
+
+	d.certContents = certContents
+	d.certificates.Reset(certificates)
+	d.client = core.NewClientWithValidator(
+		d.client,
+		validators.NewWechatPayResponseValidator(verifiers.NewSHA256WithRSAVerifier(d)),
+	)
+}
+
+func (d *CertificateDownloader) performDownloading(ctx context.Context) (*downloadCertificatesResponse, error) {
+	result, err := d.client.Get(ctx, consts.WechatPayAPIServer+"/v3/certificates")
+	if err != nil {
+		return nil, err
+	}
+
+	resp := new(downloadCertificatesResponse)
+	if err = core.UnMarshalResponse(result.Response, resp); err != nil {
+		return nil, err
+	}
+	return resp, nil
+}
+
+// DownloadCertificates 立即下载平台证书列表
+func (d *CertificateDownloader) DownloadCertificates(ctx context.Context) error {
+	resp, err := d.performDownloading(ctx)
+	if err != nil {
+		return err
+	}
+
+	rawCertContentMap := make(map[string]string)
+	certificateMap := make(map[string]*x509.Certificate)
+	for _, rawCertificate := range resp.Data {
+		certContent, err := d.decryptCertificate(ctx, rawCertificate.EncryptCertificate)
+		if err != nil {
+			return err
+		}
+
+		certificate, err := utils.LoadCertificate(certContent)
+		if err != nil {
+			return fmt.Errorf("parse downlaoded certificate failed: %v, certcontent:%v", err, certContent)
+		}
+
+		serialNumber := *rawCertificate.SerialNo
+
+		rawCertContentMap[serialNumber] = certContent
+		certificateMap[serialNumber] = certificate
+	}
+
+	if len(certificateMap) == 0 {
+		return fmt.Errorf("no certificate downloaded")
+	}
+
+	d.updateCertificates(ctx, rawCertContentMap, certificateMap)
+	return nil
+}
+
+// NewCertificateDownloader 使用商户号/商户私钥等信息初始化商户的平台证书下载器 CertificateDownloader
+// 初始化完成后会立即发起一次下载,确保下载器被正确初始化。
+func NewCertificateDownloader(
+	ctx context.Context, mchID string, privateKey *rsa.PrivateKey, certificateSerialNo string, mchAPIv3Key string,
+) (*CertificateDownloader, error) {
+	settings := core.DialSettings{
+		Signer: &signers.SHA256WithRSASigner{
+			MchID:               mchID,
+			PrivateKey:          privateKey,
+			CertificateSerialNo: certificateSerialNo,
+		},
+		Validator: &validators.NullValidator{},
+	}
+
+	client, err := core.NewClientWithDialSettings(ctx, &settings)
+	if err != nil {
+		return nil, fmt.Errorf("create downloader failed, create client err:%v", err)
+	}
+
+	return NewCertificateDownloaderWithClient(ctx, client, mchAPIv3Key)
+}
+
+// NewCertificateDownloaderWithClient 使用 core.Client 初始化商户的平台证书下载器 CertificateDownloader
+// 初始化完成后会立即发起一次下载,确保下载器被正确初始化。
+func NewCertificateDownloaderWithClient(
+	ctx context.Context, client *core.Client, mchAPIv3Key string,
+) (*CertificateDownloader, error) {
+	downloader := CertificateDownloader{
+		client:      client,
+		mchAPIv3Key: mchAPIv3Key,
+	}
+
+	if err := downloader.DownloadCertificates(ctx); err != nil {
+		return nil, err
+	}
+
+	return &downloader, nil
+}

+ 245 - 0
core/downloader/downloader_mgr.go

@@ -0,0 +1,245 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package downloader
+
+import (
+	"context"
+	"crypto/rsa"
+	"crypto/x509"
+	"sync"
+	"time"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils/task"
+)
+
+const (
+	// DefaultDownloadInterval 默认微信支付平台证书更新间隔
+	DefaultDownloadInterval = 24 * time.Hour
+)
+
+type pseudoCertificateDownloader struct {
+	mgr   *CertificateDownloaderMgr
+	mchID string
+}
+
+// GetAll 获取平台证书Map
+func (d *pseudoCertificateDownloader) GetAll(ctx context.Context) map[string]*x509.Certificate {
+	return d.mgr.GetCertificateMap(ctx, d.mchID)
+}
+
+// Get 获取证书序列号对应的平台证书
+func (d *pseudoCertificateDownloader) Get(ctx context.Context, serialNumber string) (*x509.Certificate, bool) {
+	return d.mgr.GetCertificate(ctx, d.mchID, serialNumber)
+}
+
+// GetNewestSerial 获取最新的平台证书的证书序列号
+func (d *pseudoCertificateDownloader) GetNewestSerial(ctx context.Context) string {
+	return d.mgr.GetNewestCertificateSerial(ctx, d.mchID)
+}
+
+// ExportAll 获取平台证书内容Map
+func (d *pseudoCertificateDownloader) ExportAll(ctx context.Context) map[string]string {
+	return d.mgr.ExportCertificateMap(ctx, d.mchID)
+}
+
+// Export 获取证书序列号对应的平台证书内容
+func (d *pseudoCertificateDownloader) Export(ctx context.Context, serialNumber string) (string, bool) {
+	return d.mgr.ExportCertificate(ctx, d.mchID, serialNumber)
+}
+
+// CertificateDownloaderMgr 证书下载器管理器
+// 可挂载证书下载器 CertificateDownloader,会定时调用 CertificateDownloader 下载最新的证书
+//
+// CertificateDownloaderMgr 不会被 GoGC 自动回收,不再使用时应调用 Stop 方法,防止发生资源泄漏
+type CertificateDownloaderMgr struct {
+	ctx           context.Context
+	task          *task.RepeatedTask
+	downloaderMap map[string]*CertificateDownloader
+	lock          sync.RWMutex
+}
+
+// Stop 停止 CertificateDownloaderMgr 的自动下载 Goroutine
+// 当且仅当不再需要当前管理器自动下载后调用
+// 一旦调用成功,当前管理器无法再次启动
+func (mgr *CertificateDownloaderMgr) Stop() {
+	mgr.lock.Lock()
+	defer mgr.lock.Unlock()
+
+	mgr.task.Stop()
+}
+
+// GetCertificate 获取商户的某个平台证书
+func (mgr *CertificateDownloaderMgr) GetCertificate(ctx context.Context, mchID, serialNumber string) (
+	*x509.Certificate, bool,
+) {
+	mgr.lock.RLock()
+	downloader, ok := mgr.downloaderMap[mchID]
+	mgr.lock.RUnlock()
+
+	if !ok {
+		return nil, false
+	}
+
+	return downloader.Get(ctx, serialNumber)
+}
+
+// GetCertificateMap 获取商户的平台证书Map
+func (mgr *CertificateDownloaderMgr) GetCertificateMap(ctx context.Context, mchID string) map[string]*x509.Certificate {
+	mgr.lock.RLock()
+	downloader, ok := mgr.downloaderMap[mchID]
+	mgr.lock.RUnlock()
+
+	if !ok {
+		return nil
+	}
+	return downloader.GetAll(ctx)
+}
+
+// GetNewestCertificateSerial 获取商户的最新的平台证书序列号
+func (mgr *CertificateDownloaderMgr) GetNewestCertificateSerial(ctx context.Context, mchID string) string {
+	mgr.lock.RLock()
+	downloader, ok := mgr.downloaderMap[mchID]
+	mgr.lock.RUnlock()
+
+	if !ok {
+		return ""
+	}
+	return downloader.GetNewestSerial(ctx)
+}
+
+// ExportCertificate 获取商户的某个平台证书内容
+func (mgr *CertificateDownloaderMgr) ExportCertificate(ctx context.Context, mchID, serialNumber string) (string, bool) {
+	mgr.lock.RLock()
+	downloader, ok := mgr.downloaderMap[mchID]
+	mgr.lock.RUnlock()
+
+	if !ok {
+		return "", false
+	}
+
+	return downloader.Export(ctx, serialNumber)
+}
+
+// ExportCertificateMap 导出商户的平台证书内容Map
+func (mgr *CertificateDownloaderMgr) ExportCertificateMap(ctx context.Context, mchID string) map[string]string {
+	mgr.lock.RLock()
+	downloader, ok := mgr.downloaderMap[mchID]
+	mgr.lock.RUnlock()
+
+	if !ok {
+		return nil
+	}
+	return downloader.ExportAll(ctx)
+}
+
+// GetCertificateVisitor 获取某个商户的平台证书访问器
+func (mgr *CertificateDownloaderMgr) GetCertificateVisitor(mchID string) core.CertificateVisitor {
+	return &pseudoCertificateDownloader{mgr: mgr, mchID: mchID}
+}
+
+func (mgr *CertificateDownloaderMgr) getTickHandler() func(time.Time) {
+	return func(time.Time) {
+		mgr.DownloadCertificates(mgr.ctx)
+	}
+}
+
+// DownloadCertificates 让所有已注册下载器均进行一次下载
+func (mgr *CertificateDownloaderMgr) DownloadCertificates(ctx context.Context) {
+	tmpDownloaderMap := make(map[string]*CertificateDownloader)
+
+	mgr.lock.RLock()
+	for key, downloader := range mgr.downloaderMap {
+		tmpDownloaderMap[key] = downloader
+	}
+	mgr.lock.RUnlock()
+
+	for _, downloader := range tmpDownloaderMap {
+		_ = downloader.DownloadCertificates(ctx)
+	}
+}
+
+// RegisterDownloaderWithPrivateKey 向 Mgr 注册商户的平台证书下载器
+func (mgr *CertificateDownloaderMgr) RegisterDownloaderWithPrivateKey(
+	ctx context.Context, privateKey *rsa.PrivateKey,
+	certificateSerialNo string, mchID string, mchAPIv3Key string,
+) error {
+	downloader, err := NewCertificateDownloader(ctx, mchID, privateKey, certificateSerialNo, mchAPIv3Key)
+	if err != nil {
+		return err
+	}
+
+	mgr.lock.Lock()
+	defer mgr.lock.Unlock()
+
+	mgr.downloaderMap[mchID] = downloader
+	return nil
+}
+
+// RegisterDownloaderWithClient 向 Mgr 注册商户的平台证书下载器
+func (mgr *CertificateDownloaderMgr) RegisterDownloaderWithClient(
+	ctx context.Context, client *core.Client, mchID string, mchAPIv3Key string,
+) error {
+	downloader, err := NewCertificateDownloaderWithClient(ctx, client, mchAPIv3Key)
+	if err != nil {
+		return err
+	}
+
+	mgr.lock.Lock()
+	defer mgr.lock.Unlock()
+
+	mgr.downloaderMap[mchID] = downloader
+	return nil
+}
+
+// RemoveDownloader 移除商户的平台证书下载器
+// 移除后从 GetCertificateVisitor 接口获得的对应商户的 CertificateVisitor 将会失效,
+// 请确认不再需要该商户的证书后再行移除,如果下载器存在,本接口将会返回该下载器。
+func (mgr *CertificateDownloaderMgr) RemoveDownloader(_ context.Context, mchID string) *CertificateDownloader {
+	mgr.lock.Lock()
+	defer mgr.lock.Unlock()
+
+	downloader, ok := mgr.downloaderMap[mchID]
+	if !ok {
+		return nil
+	}
+
+	delete(mgr.downloaderMap, mchID)
+	return downloader
+}
+
+// HasDownloader 检查是否已经注册过 mchID 这个商户的下载器
+func (mgr *CertificateDownloaderMgr) HasDownloader(_ context.Context, mchID string) bool {
+	mgr.lock.RLock()
+	defer mgr.lock.RUnlock()
+
+	_, ok := mgr.downloaderMap[mchID]
+	return ok
+}
+
+// NewCertificateDownloaderMgr 以默认间隔 DefaultDownloadInterval 创建证书下载管理器
+// 该管理器将以 DefaultDownloadInterval 的间隔定期调度所有 Downloader 进行证书下载。
+// 证书管理器一旦创建即启动,使用完毕请调用 Stop() 防止发生资源泄漏
+func NewCertificateDownloaderMgr(ctx context.Context) *CertificateDownloaderMgr {
+	return NewCertificateDownloaderMgrWithInterval(ctx, DefaultDownloadInterval)
+}
+
+// NewCertificateDownloaderMgrWithInterval 创建一个空证书下载管理器(自定义更新间隔)
+//
+// 更新间隔最大不建议超过 2 天,以免错过平台证书平滑切换窗口;
+// 同时亦不建议小于 1 小时,以避免过多请求导致浪费
+func NewCertificateDownloaderMgrWithInterval(
+	ctx context.Context, downloadInterval time.Duration,
+) *CertificateDownloaderMgr {
+	if downloadInterval <= 0 {
+		downloadInterval = DefaultDownloadInterval
+	}
+
+	downloader := CertificateDownloaderMgr{
+		ctx:           ctx,
+		downloaderMap: make(map[string]*CertificateDownloader),
+	}
+	downloader.task = task.NewRepeatedTask(downloadInterval, downloader.getTickHandler())
+	downloader.task.Start()
+	return &downloader
+}

+ 36 - 0
core/downloader/downloader_mgr_singleton.go

@@ -0,0 +1,36 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package downloader
+
+import (
+	"context"
+	"sync"
+)
+
+var (
+	mgrInstance *CertificateDownloaderMgr
+	mgrLock     sync.RWMutex
+)
+
+// MgrInstance 获取 CertificateDownloaderMgr 默认单例,将在首次调用本方法后初始化
+// 本单例旨在伴随整个进程生命周期持续运行,请不要调用其 Stop 方法,否则可能影响平台证书的自动更新
+//
+// 如果你希望自行管理 Mgr 的生命周期,请使用 NewCertificateDownloaderMgr 方法创建额外的Mgr
+func MgrInstance() *CertificateDownloaderMgr {
+	// 首次访问使用读锁
+	mgrLock.RLock()
+	if mgrInstance != nil {
+		defer mgrLock.RUnlock()
+		return mgrInstance
+	}
+	mgrLock.RUnlock()
+
+	// 确认不存在后切换为写锁,由于 Go 没有读锁升级写锁的能力,因此解锁并重新捕获后,需要再次检查是否存在
+	mgrLock.Lock()
+	defer mgrLock.Unlock()
+
+	if mgrInstance == nil {
+		mgrInstance = NewCertificateDownloaderMgr(context.Background())
+	}
+	return mgrInstance
+}

+ 55 - 0
core/downloader/downloader_mgr_test.go

@@ -0,0 +1,55 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package downloader_test
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/option"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+func TestAutoCertificateDownloader(t *testing.T) {
+	patches := mockDownloadServer(t)
+	defer patches.Reset()
+
+	ctx := context.Background()
+
+	mgr := downloader.NewCertificateDownloaderMgrWithInterval(ctx, 5*time.Second)
+	require.NotNil(t, mgr)
+	defer mgr.Stop()
+
+	privateKey, err := utils.LoadPrivateKey(testingKey(mockMchPrivateKey))
+	require.NoError(t, err)
+	opts := []core.ClientOption{
+		option.WithMerchantCredential(mockMchID, mockMchCertificateSerial, privateKey),
+		option.WithoutValidator(),
+	}
+
+	client, err := core.NewClient(ctx, opts...)
+	require.NoError(t, err)
+
+	err = mgr.RegisterDownloaderWithClient(ctx, client, mockMchID, mockAPIv3Key)
+	require.NoError(t, err)
+
+	err = mgr.RegisterDownloaderWithPrivateKey(ctx, privateKey, mockMchCertificateSerial, mockMchID, mockAPIv3Key)
+	require.NoError(t, err)
+
+	provider := mgr.GetCertificateVisitor(mockMchID)
+
+	assert.NotEmpty(t, provider.GetAll(ctx))
+	for serialNo, cert := range provider.GetAll(ctx) {
+		assert.Equal(t, serialNo, utils.GetCertificateSerialNumber(*cert))
+	}
+
+	time.Sleep(11 * time.Second)
+
+	mgr.RemoveDownloader(ctx, mockMchID)
+	assert.Empty(t, provider.GetAll(ctx))
+}

+ 76 - 0
core/downloader/downloader_test.go

@@ -0,0 +1,76 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package downloader_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/option"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+func TestNewCertificateDownloaderWithClient(t *testing.T) {
+	patches := mockDownloadServer(t)
+	defer patches.Reset()
+
+	ctx := context.Background()
+
+	privateKey, err := utils.LoadPrivateKey(testingKey(mockMchPrivateKey))
+	require.NoError(t, err)
+	opts := []core.ClientOption{
+		option.WithMerchantCredential(mockMchID, mockMchCertificateSerial, privateKey),
+		option.WithoutValidator(),
+	}
+
+	client, err := core.NewClient(ctx, opts...)
+	require.NoError(t, err)
+
+	d, err := downloader.NewCertificateDownloaderWithClient(ctx, client, mockAPIv3Key)
+	require.NoError(t, err)
+
+	assert.NotEmpty(t, d.GetAll(ctx))
+	for serialNo, cert := range d.GetAll(ctx) {
+		assert.Equal(t, serialNo, utils.GetCertificateSerialNumber(*cert))
+	}
+
+	// call Download
+	err = d.DownloadCertificates(ctx)
+	require.NoError(t, err)
+
+	// call Download Again
+	err = d.DownloadCertificates(ctx)
+	require.NoError(t, err)
+}
+
+func TestNewCertificateDownloader(t *testing.T) {
+	patches := mockDownloadServer(t)
+	defer patches.Reset()
+
+	privateKey, err := utils.LoadPrivateKey(testingKey(mockMchPrivateKey))
+	require.NoError(t, err)
+
+	ctx := context.Background()
+
+	d, err := downloader.NewCertificateDownloader(
+		context.Background(), mockMchID, privateKey, mockMchCertificateSerial, mockAPIv3Key,
+	)
+	require.NoError(t, err)
+
+	assert.NotEmpty(t, d.GetAll(ctx))
+	for serialNo, cert := range d.GetAll(ctx) {
+		assert.Equal(t, serialNo, utils.GetCertificateSerialNumber(*cert))
+	}
+
+	// call Download
+	err = d.DownloadCertificates(ctx)
+	require.NoError(t, err)
+
+	// call Download Again
+	err = d.DownloadCertificates(ctx)
+	require.NoError(t, err)
+}

+ 92 - 0
core/downloader/example_test.go

@@ -0,0 +1,92 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package downloader_test
+
+import (
+	"context"
+	"crypto/rsa"
+	"fmt"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/option"
+)
+
+func ExampleNewCertificateDownloader_saveCert() {
+	ctx := context.Background()
+
+	var (
+		mchID                      string
+		mchCertificateSerialNumber string
+		mchPrivateKey              *rsa.PrivateKey
+		mchAPIv3Key                string
+	)
+	// 假设以上参数已初始化完成
+
+	d, err := downloader.NewCertificateDownloader(ctx, mchID, mchPrivateKey, mchCertificateSerialNumber, mchAPIv3Key)
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	for serialNumber, certificateContent := range d.ExportAll(ctx) {
+		// 将 certificateContent 写入文件 *.pem
+		_, _ = serialNumber, certificateContent
+	}
+}
+
+func ExampleNewCertificateDownloaderMgr() {
+	ctx := context.Background()
+	mgr := downloader.NewCertificateDownloaderMgr(ctx)
+	// CertificateDownloaderMgr 初始化完成,尚未注册任何 Downloader,不会进行任何证书下载
+
+	var (
+		mchID                      string
+		mchCertificateSerialNumber string
+		mchPrivateKey              *rsa.PrivateKey
+		mchAPIv3Key                string
+	)
+	// 假设以上参数已初始化完成
+
+	// 注册证书下载器
+	if err := mgr.RegisterDownloaderWithPrivateKey(
+		ctx, mchPrivateKey, mchCertificateSerialNumber, mchID, mchAPIv3Key,
+	); err == nil {
+		fmt.Println(err)
+		return
+	}
+	// 可以注册多个商户的证书下载器...
+
+	// 获取证书访问器
+	certificateVisitor := mgr.GetCertificateVisitor(mchID)
+
+	// 使用 certificateVisitor 初始化 Validator 进行验签
+	option.WithVerifier(verifiers.NewSHA256WithRSAVerifier(certificateVisitor))
+}
+
+func ExampleNewCertificateDownloaderMgr_useMgr() {
+	var certificateDownloaderMgr *downloader.CertificateDownloaderMgr
+	// certificateDownloaderMgr 已经初始化完成且注册了需要的 Downloader
+
+	var (
+		mchID                      string
+		mchCertificateSerialNumber string
+		mchPrivateKey              *rsa.PrivateKey
+	)
+
+	ctx := context.Background()
+	client, err := core.NewClient(
+		ctx,
+		option.WithWechatPayAutoAuthCipherUsingDownloaderMgr(
+			mchID, mchCertificateSerialNumber, mchPrivateKey, certificateDownloaderMgr,
+		),
+	)
+
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+	// 使用下载管理器初始化 Client 成功
+	_ = client
+}

+ 182 - 0
core/downloader/mock_download_server_test.go

@@ -0,0 +1,182 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package downloader_test
+
+import (
+	"bytes"
+	"crypto/rsa"
+	"crypto/x509"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"reflect"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/agiledragon/gomonkey"
+	"github.com/stretchr/testify/require"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+const (
+	mockWechatPayPrivateKeyStr = `-----BEGIN TESTING KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZUJN33V+dSfvd
+fL0Mu+39XrZNXFFMQSy1V15FpncHeV47SmV0TzTqZc7hHB0ddqAdDi8Z5k3TKqb7
+6sOwYr5TcAfuR6PIPaleyE0/0KrljBum2Isa2Nyq7Dgc3ElBQ6YN4l/a+DpvKaz1
+FSKmKrhLNskqokWVSlu4g8OlKlbPXQ9ibII14MZRQrrkTmHYHzfi7GXXM0thAKuR
+0HNvyhTHBh4/lrYM3GaMvmWwkwvsMavnOex6+eioZHBOb1/EIZ/LzC6zuHArPpyW
+3daGaZ1rtQB1vVzTyERAVVFsXXgBHvfFud3w3ShsJYk8JvMwK2RpJ5/gV0QSARcm
+LDRUAlPzAgMBAAECggEBAMc7rDeUaXiWv6bMGbZ3BTXpg1FhdddnWUnYE8HfX/km
+OFI7XtBHXcgYFpcjYz4D5787pcsk7ezPidAj58zqenuclmjKnUmT3pfbI5eCA2v4
+C9HnbYDrmUPK1ZcADtka4D6ScDccpNYNa1g2TFHzkIrEa6H+q7S3O2fqxY/DRVtN
+0JIXalBb8daaqL5QVzSmM2BMVnHy+YITJWIkP2a3pKs9C0W65JGDsnG0wVrHinHF
++cnhFZIbaPEI//DAFMc9NkrWOKVRTEgcCUxCFaHOZVNxDWZD7A2ZfJB2rK6eg//y
+gEiFDR2h6mTaDowMB4YF2n2dsIO4/dCG8vPHI20jn4ECgYEA/ZGu6lEMlO0XZnam
+AZGtiNgLcCfM/C2ZERZE7QTRPZH1WdK92Al9ndldsswFw4baJrJLCmghjF/iG4zi
+hhBvLnOLksnZUfjdumxoHDWXo2QBWbI5QsWIE7AuTiWgWj1I7X4fCXSQf6i+M/y2
+6TogQ7d0ANpZFyOkTNMn/tiJvLECgYEA22XqlamG/yfAGWery5KNH2DGlTIyd6xJ
+WtJ9j3jU99lZ0bCQ5xhiBbU9ImxCi3zgTsoqLWgA/p00HhNFNoUcTl9ofc0G3zwT
+D1y0ZzcnVKxGJdZ6ohW52V0hJStAigtjYAsUgjm7//FH7PiQDBDP1Wa6xSRkDQU/
+aSbQxvEE8+MCgYEA3bb8krW7opyM0XL9RHH0oqsFlVO30Oit5lrqebS0oHl3Zsr2
+ZGgoBlWBsEzk3UqUhTFwm/DhJLTSJ/TQPRkxnhQ5/mewNhS9C7yua7wQkzVmWN+V
+YeUGTvDGDF6qDz12/vJAgSwDDRym8x4NcXD5tTw7mmNRcwIfL22SkysThIECgYAV
+BgccoEoXWS/HP2/u6fQr9ZIR6eV8Ij5FPbZacTG3LlS1Cz5XZra95UgebFFUHHtC
+EY1JHJY7z8SWvTH8r3Su7eWNaIAoFBGffzqqSVazfm6aYZsOvRY6BfqPHT3p/H1h
+Tq6AbBffxrcltgvXnCTORjHPglU0CjSxVs7awW3AEQKBgB5WtaC8VLROM7rkfVIq
++RXqE5vtJfa3e3N7W3RqxKp4zHFAPfr82FK5CX2bppEaxY7SEZVvVInKDc5gKdG/
+jWNRBmvvftZhY59PILHO2X5vO4FXh7suEjy6VIh0gsnK36mmRboYIBGsNuDHjXLe
+BDa+8mDLkWu5nHEhOxy2JJZl
+-----END TESTING KEY-----`
+	mockWechatPayCertificateStr = `-----BEGIN CERTIFICATE-----
+MIIDVzCCAj+gAwIBAgIJANfOWdH1ItcBMA0GCSqGSIb3DQEBCwUAMEIxCzAJBgNV
+BAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQg
+Q29tcGFueSBMdGQwHhcNMjEwNDI3MDg1NTIzWhcNMzEwNDI1MDg1NTIzWjBCMQsw
+CQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZh
+dWx0IENvbXBhbnkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
+2VCTd91fnUn73Xy9DLvt/V62TVxRTEEstVdeRaZ3B3leO0pldE806mXO4RwdHXag
+HQ4vGeZN0yqm++rDsGK+U3AH7kejyD2pXshNP9Cq5YwbptiLGtjcquw4HNxJQUOm
+DeJf2vg6byms9RUipiq4SzbJKqJFlUpbuIPDpSpWz10PYmyCNeDGUUK65E5h2B83
+4uxl1zNLYQCrkdBzb8oUxwYeP5a2DNxmjL5lsJML7DGr5znsevnoqGRwTm9fxCGf
+y8wus7hwKz6clt3Whmmda7UAdb1c08hEQFVRbF14AR73xbnd8N0obCWJPCbzMCtk
+aSef4FdEEgEXJiw0VAJT8wIDAQABo1AwTjAdBgNVHQ4EFgQUT1c7nd/SUO76HSoZ
+umNUJv1R5PwwHwYDVR0jBBgwFoAUT1c7nd/SUO76HSoZumNUJv1R5PwwDAYDVR0T
+BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAfTjxKRQMzNB/U6ZoCUS+BSNfa2Oh
+0plMN6ZuzwiVVZwg1jywvv5yv04koS7Pd4i9E4gt9ZBUQXlpq+A3oOCEEHNRR6b2
+kyazGRM7s0OP5X21WrbpSmKmU6K7hkfx30yYs08LVs/Q8DIhvaj1FCFeJzUCzYn/
+fHMq4tsbKO0dKAeydPM/nrUZBmaYQVKMVOORGLFjFKVO7JV6Kq/R86ouhjEPgJOe
+2xulNBUcjicqtZlBdEh/PWCYP2SpGVDclKm8jeo175T3EVAkdKzzmfpxtMmnMlmq
+cTJOU9TxuGvNASMtjj7pYIerTx+xgZDXEVBWFW9PjJ0TV06tCRsgSHItgg==
+-----END CERTIFICATE-----`
+	mockAPIv3Key             = "mockAPIv3Key1234"
+	mockMchID                = "1234567890"
+	mockMchCertificateSerial = "BE6DCDA7A5931FA0"
+	mockMchPrivateKey        = `-----BEGIN TESTING KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/KuDQhHMw9Rkv
+IUvXrBgCNTqhWAxdu1O4pdSzeaJJYeUQPPP3KLlm6jjvjZg8nOS2zv0hZRGHNviS
+Am70+HKsRlZThbB6Kz08c/jPN01CoLJ1bLGFEX4tvcmalM5RGyRZrhXYSWtO/aDr
+ezC1HRTRrKCvGq0utVb8wJz3NykX2U5PN3g7VliADFJvAjcLH9ctNwl/DU6+uNVd
+3ch/REZIhM/8K3UKYULTBpg3Hx2oKmFdRfay5Fy7Q1snrz3ziIciNKfynu6dWQ0R
+f2pvkulTLASDDbQD2k1z/oStV29O/VcnYTuwVhQmOM0v8v6jWNhnqxzlMG8QY4uR
+K2UrmvIpAgMBAAECggEBAJmmWgne20MXTMWfynivnqBvrg8uWMohnZoE15/lfLXk
+lBroEuKt/c6lJVwNf7YAGKiCV9w2gs8eNM4OXKZS5sBmzE3XX0/iXxau0+WhOlz6
+ElXlJg2hULxtFZREVEvGOAJItNAhurlDi8qZOj3oAOrCCGiVVWr8X81I9yCQWlwK
+a46UoWixU8E0W4jFFt4AFLbrMPa+7TsKr9gilYcwcNHvKDqYhuqKdGRmO+QtKlPw
+4AdlfhwUh1L+TCUcDVb6Zcby+HWUpcx7hBcKRd8sLxPeWcS8imeR8OlVO/8bRJYD
+WhFlBRqvIuJqycW5/v01ka3zRe1yO/bl+sxJ6QHPrA0CgYEA+/7+97lmNeKIfFYS
+4JD8jiAZeCl9C6CZFj3gCpXE3ChT9wGsHdkw2QZRZkKYDFMhF7KVtiijvexSeWaB
+uXelk+3yHhmVUC8DH1aXh2CXGgJhhxE08YzBtFp7oqn8dd2SXQAf37FGjjlhCcnw
+JqgE212qdFzY1nrTCjS6ymTdv5sCgYEAwjR1pAXW5mR/PXMH/8i+Wr3ZeZawHByN
+/H4wtw5F/mR7xzJup1y6XA0q7BEH+zp5ts0nKzOwIr6uZsBJl2xUuntD/pZCwsN+
+6P+mC1JGWKYhXTZ8PASVU834wqUjaCDBxXZqFqkIZAnjQZhpC7meSJ1ANAt6f5zq
+/DhoyI8My4sCgYAkxkW3KRs9ad25J2aB1ybEJvMQkh1pgPpWQJldchXUex4lwdy4
+TmXOhhmC7tz5j3gY2Tr35l5e7QnsJYVw54EGYYcf1WPw26t8+0oJu5LRfN92spAj
+YAM0qq+4QU5SdQ9S+x2rq1c8kisTHqEpQwPSb4zchmAujKYXqzJHLwkdWQKBgCV9
+EPR/uBMzdSh8ix/CNZS4r0F8aDSVAoeqKGc91x8bcJVsU9X03XilhhKZ2wuRJyu1
+gIkjai3a1zm4hrw3SkfRQbfkc7C7IkWCDCCFWlUEhM5ElUjUrarGyO1yCVqxcBZZ
+HHORX7BIBFmGPUjpJPfpexpQ2O3HcckMbpXAn3yvAoGAPxQSNSa8JKGgdD8pZQIC
+eQU9Br/ZPXtI1EZdxfMwdwFov+6DtPaq5kt01wACK9ozTHONLFH3diW+HIl+49r6
+0q+pf03FucCa+chixggyeyvV5Zi2KrV5CRH+tRVDy75Erf6YgjTzoXrPfZZ1FIzM
+wvAZNrxhvtBilUC8adsqhSY=
+-----END TESTING KEY-----`
+
+	mockNonce = "mockNonce1234"
+	data      = "{\"data\":[{\"effective_time\":\"2021-04-27T16:55:23+08:00\",\"encrypt_certificate\":{" +
+		"\"algorithm\":\"AEAD_AES_256_GCM\"," +
+		"\"associated_data\":\"certificate\"," +
+		"\"ciphertext\":\"QHBP2NGCmcEhGWa1f+t5AeImEdkS+XyUx1nB50LLGTgfDdb+EELcl9/GRJR0m9SFcTSlsS67QKemf2DuvPGTFEn" +
+		"EIhZ/I26zym7Ift1YJt7ftiVqoU4lv9aopCWD7IrzQBLT7XeYKh0CFHj0fUeqnNrGC+3EMj+NHbDY6zGfW4SfhvzLxO6tvpQ3xbFyT2C" +
+		"gxHmiKiVTxkewiSDuaimy3X/SN/Nq901lhEtqBVMFyFOyT1MvTL0RUWpU+l4Wdyz9xGTrrv0pd7regqfXwklx+Y94ULUd7lhSyb77h9j" +
+		"8golVvxYUwrUrxWj5ri6APPd8qedWuxkBjOPUAho/q1ETpjYJqK0eXuVWf5EPBS63jvqEE4IfopXOwltPLEME++6u7H4qjhAfzdUvfLQ" +
+		"awUe/zPPwWS5GOWRxov7qnuK3aV0+ybZM33hmPwP5LsPNQlDdaQxcNgqbnmpVWjxCPdiiZ52z19AxcB4Ry9LN5IBAEbTnm9WnCp4cOqN" +
+		"OXBr2/l+FRLSqZnNTIFbOzXWKw5AngHbciLEvTYEsFNpT/u1SBCU2xQ3Y+Q8OHIn6kmE3AO/gUmXjciLv05ZGo+DCAnqGOVdr2yCLv2j" +
+		"maBSIcrComs1WzliuNfFJ5+On7i+rOAbKMaZLdTcB//Lpa4mfkhULhslQYUT5H4XbQqsi5IPGplYvvNh2+ktahI6lYWnCprFeFthyEfy" +
+		"LNc9MGtsO1rdsZqoI6ed+KLzMJDOGAhgEQqSJIOzz546/pyn5DnINlxjMSpq6+Zdan+iuiz3Un2idaLf6iPSx1FtdNPi94EjlI+bQSEi" +
+		"hKKm4Em2xVgauajO3mK6+JTPNQMVrbtEV9wIGHvPLVm6Uw3OAuLWL0UCn3P6wuxF7+4X+S8s1EKN/3q/w++U93NKdfnpOKe7Vxg7Pg3f" +
+		"96WQzJTvRJ7C1Xm4W7GvS09hGjCNGCvbcGXla14X/Y35o6Uf37NlnmnDe2rdcnmYsXE5qooS0ThLdOKdKNybg+ih9iFPcxdTJ67WfvVP" +
+		"vaOhLrRm8cDgTYjfVYAj7lxztRYnZE65PTeWUE/mLZTd52g3WEE8ty79KLcqnzARGq7sjdptcZQ4Vw11hHA3PbrxQdyvuyXbONZzjrMR" +
+		"0RsOmJPfyQTYPQuh6xdbcLP8bQiibREl+iOop0PLYRu9GtQ+r2uEI4VIuuNW1DwFzJRkWdxPm8kjC+g7XLDWqBYImFfep9Dfxj6Jam/w" +
+		"ILKWVg3JjfuZe6nTwK7xqwDsY4Ylj8rnjnf+Cw3XQh94IU2E3uq3wqhoR1NCM7Qky+a6JIMGCALiqlj2DMnUgPKWEwFJ7bigRCdC+0s+" +
+		"QQaeIEQ04Rm8jeuHaNvEsfloqLNrWx0D3ZHeIKB6hEegNnpSllfPJ/lF1U3ZnhMYd6oUef5HuV4wcu4oPf0nCEBi/CGlyRhmRKaondms" +
+		"puRv5C4/zqQrqaVfJrkL0XO6EnhaxI0Yh6t5piA6vw73LtNH5fI55d1S2KWu0zUVbfyijzFohSfVZ5Zryo8uSggAgE96O+jiYzXfuS0d" +
+		"vCVhXW/lw0ekQnKMx0Xh6U7XIckIvLgb4QQ6Oqv4/GZBo4dV7s78pNj/KvcCI6ya6qfrWgy1+pWmOLO+wcTmfzYOae4IyZnmuDXwyPng=\"," +
+		"\"nonce\":\"3a584b49ed9b\"}," +
+		"\"expire_time\":\"2031-04-25T16:55:23+08:00\",\"serial_no\":\"D7CE59D1F522D701\"}]}"
+)
+
+var mockWechatPayPrivateKey *rsa.PrivateKey
+var mockWechatPayCertificate *x509.Certificate
+
+func init() {
+	var err error
+
+	if mockWechatPayPrivateKey, err = utils.LoadPrivateKey(testingKey(mockWechatPayPrivateKeyStr)); err != nil {
+		panic("mockWechatPayPrivateKeyStr is invalid")
+	}
+	if mockWechatPayCertificate, err = utils.LoadCertificate(mockWechatPayCertificateStr); err != nil {
+		panic("mockWechatPayCertificateStr is invalid")
+	}
+}
+
+func mockDownloadServer(t *testing.T) *gomonkey.Patches {
+	patches := gomonkey.NewPatches()
+	patches.ApplyMethod(
+		reflect.TypeOf(&http.Client{}), "Do", func(_ *http.Client, req *http.Request) (*http.Response, error) {
+			resp := http.Response{
+				Status:        "200 OK",
+				StatusCode:    200,
+				Proto:         "HTTP/1.1",
+				ProtoMajor:    1,
+				ProtoMinor:    1,
+				Header:        http.Header{},
+				Body:          ioutil.NopCloser(bytes.NewBufferString(data)),
+				ContentLength: int64(len(data)),
+				Request:       req,
+			}
+
+			resp.Header.Set(consts.ContentLength, strconv.Itoa(len(data)))
+			resp.Header.Set(consts.ContentType, "application/json; charset=utf-8")
+
+			resp.Header.Set(consts.RequestID, "mock-request-id")
+			resp.Header.Set(consts.WechatPaySerial, utils.GetCertificateSerialNumber(*mockWechatPayCertificate))
+			resp.Header.Set(consts.WechatPayNonce, mockNonce)
+
+			timestamp := strconv.FormatInt(time.Now().Unix(), 10)
+			resp.Header.Set(consts.WechatPayTimestamp, timestamp)
+
+			signature, err := utils.SignSHA256WithRSA(
+				fmt.Sprintf("%s\n%s\n%s\n", timestamp, mockNonce, data), mockWechatPayPrivateKey,
+			)
+			require.NoError(t, err)
+
+			resp.Header.Set(consts.WechatPaySignature, signature)
+
+			return &resp, nil
+		},
+	)
+	return patches
+}
+
+func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") }

+ 164 - 0
core/downloader/models.go

@@ -0,0 +1,164 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package downloader
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+)
+
+// rawCertificate 微信支付平台证书信息
+type rawCertificate struct {
+	// 证书序列号
+	SerialNo *string `json:"serial_no"`
+	// 证书有效期开始时间
+	EffectiveTime *time.Time `json:"effective_time"`
+	// 证书过期时间
+	ExpireTime *time.Time `json:"expire_time"`
+	// 为了保证安全性,微信支付在回调通知和平台证书下载接口中,对关键信息进行了AES-256-GCM加密
+	EncryptCertificate *encryptCertificate `json:"encrypt_certificate"`
+}
+
+// MarshalJSON 自定义JSON序列化
+func (o rawCertificate) MarshalJSON() ([]byte, error) {
+	toSerialize := map[string]interface{}{}
+
+	if o.SerialNo == nil {
+		return nil, fmt.Errorf("field `SerialNo` is required and must be specified in rawCertificate")
+	}
+	toSerialize["serial_no"] = o.SerialNo
+
+	if o.EffectiveTime == nil {
+		return nil, fmt.Errorf("field `EffectiveTime` is required and must be specified in rawCertificate")
+	}
+	toSerialize["effective_time"] = o.EffectiveTime.Format(time.RFC3339)
+
+	if o.ExpireTime == nil {
+		return nil, fmt.Errorf("field `ExpireTime` is required and must be specified in rawCertificate")
+	}
+	toSerialize["expire_time"] = o.ExpireTime.Format(time.RFC3339)
+
+	if o.EncryptCertificate == nil {
+		return nil, fmt.Errorf("field `encryptCertificate` is required and must be specified in rawCertificate")
+	}
+	toSerialize["encrypt_certificate"] = o.EncryptCertificate
+	return json.Marshal(toSerialize)
+}
+
+// String 自定义字符串表达
+func (o rawCertificate) String() string {
+	var ret string
+	if o.SerialNo == nil {
+		ret += "SerialNo:<nil>, "
+	} else {
+		ret += fmt.Sprintf("SerialNo:%v, ", *o.SerialNo)
+	}
+
+	if o.EffectiveTime == nil {
+		ret += "EffectiveTime:<nil>, "
+	} else {
+		ret += fmt.Sprintf("EffectiveTime:%v, ", *o.EffectiveTime)
+	}
+
+	if o.ExpireTime == nil {
+		ret += "ExpireTime:<nil>, "
+	} else {
+		ret += fmt.Sprintf("ExpireTime:%v, ", *o.ExpireTime)
+	}
+
+	ret += fmt.Sprintf("encryptCertificate:%v", o.EncryptCertificate)
+
+	return fmt.Sprintf("rawCertificate{%s}", ret)
+}
+
+type downloadCertificatesResponse struct {
+	// 平台证书列表
+	Data []rawCertificate `json:"data,omitempty"`
+}
+
+// MarshalJSON 自定义JSON序列化
+func (o downloadCertificatesResponse) MarshalJSON() ([]byte, error) {
+	toSerialize := map[string]interface{}{}
+
+	if o.Data != nil {
+		toSerialize["data"] = o.Data
+	}
+	return json.Marshal(toSerialize)
+}
+
+// String 自定义字符串表达
+func (o downloadCertificatesResponse) String() string {
+	var ret string
+	ret += fmt.Sprintf("Data:%v", o.Data)
+
+	return fmt.Sprintf("downloadCertificatesResponse{%s}", ret)
+}
+
+// encryptCertificate 为了保证安全性,微信支付在回调通知和平台证书下载接口中,对关键信息进行了AES-256-GCM加密
+type encryptCertificate struct {
+	// 加密所使用的算法,目前可能取值仅为 AEAD_AES_256_GCM
+	Algorithm *string `json:"algorithm"`
+	// 加密所使用的随机字符串
+	Nonce *string `json:"nonce"`
+	// 附加数据包(可能为空)
+	AssociatedData *string `json:"associated_data"`
+	// 证书内容密文,解密后会获得证书完整内容
+	Ciphertext *string `json:"ciphertext"`
+}
+
+// MarshalJSON 自定义JSON序列化
+func (o encryptCertificate) MarshalJSON() ([]byte, error) {
+	toSerialize := map[string]interface{}{}
+
+	if o.Algorithm == nil {
+		return nil, fmt.Errorf("field `Algorithm` is required and must be specified in encryptCertificate")
+	}
+	toSerialize["algorithm"] = o.Algorithm
+
+	if o.Nonce == nil {
+		return nil, fmt.Errorf("field `Nonce` is required and must be specified in encryptCertificate")
+	}
+	toSerialize["nonce"] = o.Nonce
+
+	if o.AssociatedData == nil {
+		return nil, fmt.Errorf("field `AssociatedData` is required and must be specified in encryptCertificate")
+	}
+	toSerialize["associated_data"] = o.AssociatedData
+
+	if o.Ciphertext == nil {
+		return nil, fmt.Errorf("field `Ciphertext` is required and must be specified in encryptCertificate")
+	}
+	toSerialize["ciphertext"] = o.Ciphertext
+	return json.Marshal(toSerialize)
+}
+
+// String 自定义字符串表达
+func (o encryptCertificate) String() string {
+	var ret string
+	if o.Algorithm == nil {
+		ret += "Algorithm:<nil>, "
+	} else {
+		ret += fmt.Sprintf("Algorithm:%v, ", *o.Algorithm)
+	}
+
+	if o.Nonce == nil {
+		ret += "Nonce:<nil>, "
+	} else {
+		ret += fmt.Sprintf("Nonce:%v, ", *o.Nonce)
+	}
+
+	if o.AssociatedData == nil {
+		ret += "AssociatedData:<nil>, "
+	} else {
+		ret += fmt.Sprintf("AssociatedData:%v, ", *o.AssociatedData)
+	}
+
+	if o.Ciphertext == nil {
+		ret += "Ciphertext:<nil>"
+	} else {
+		ret += fmt.Sprintf("Ciphertext:%v", *o.Ciphertext)
+	}
+
+	return fmt.Sprintf("encryptCertificate{%s}", ret)
+}

+ 57 - 0
core/error.go

@@ -0,0 +1,57 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package core
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+)
+
+// APIError 微信支付 API v3 标准错误结构
+type APIError struct {
+	StatusCode int         // 应答报文的 HTTP 状态码
+	Header     http.Header // 应答报文的 Header 信息
+	Body       string      // 应答报文的 Body 原文
+	Code       string      `json:"code"`             // 应答报文的 Body 解析后的错误码信息,仅不符合预期/发生系统错误时存在
+	Message    string      `json:"message"`          // 应答报文的 Body 解析后的文字说明信息,仅不符合预期/发生系统错误时存在
+	Detail     interface{} `json:"detail,omitempty"` // 应答报文的 Body 解析后的详细信息,仅不符合预期/发生系统错误时存在
+}
+
+// Error 输出 APIError
+func (e *APIError) Error() string {
+	var buf bytes.Buffer
+	_, _ = fmt.Fprintf(&buf, "error http response:[StatusCode: %d Code: \"%s\"", e.StatusCode, e.Code)
+	if e.Message != "" {
+		_, _ = fmt.Fprintf(&buf, "\nMessage: %s", e.Message)
+	}
+	if e.Detail != nil {
+		var detailBuf bytes.Buffer
+		enc := json.NewEncoder(&detailBuf)
+		enc.SetIndent("", "  ")
+		if err := enc.Encode(e.Detail); err == nil {
+			_, _ = fmt.Fprint(&buf, "\nDetail:")
+			_, _ = fmt.Fprintf(&buf, "\n%s", strings.TrimSpace(detailBuf.String()))
+		}
+	}
+	if len(e.Header) > 0 {
+		_, _ = fmt.Fprint(&buf, "\nHeader:")
+		for key, value := range e.Header {
+			_, _ = fmt.Fprintf(&buf, "\n - %v=%v", key, value)
+		}
+	}
+	_, _ = fmt.Fprintf(&buf, "]")
+	return buf.String()
+}
+
+// IsAPIError 判断当前 error 是否为特定 Code 的 *APIError
+//
+// 类型为其他 error 或 Code 不匹配时均返回 false
+func IsAPIError(err error, code string) bool {
+	if ne, ok := err.(*APIError); ok {
+		return ne.Code == code
+	}
+	return false
+}

+ 44 - 0
core/notify/example_test.go

@@ -0,0 +1,44 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package notify_test
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
+	"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
+)
+
+func ExampleHandler_ParseNotifyRequest_transaction() {
+	var handler notify.Handler
+	var request *http.Request
+
+	content := new(payments.Transaction)
+	notifyReq, err := handler.ParseNotifyRequest(context.Background(), request, content)
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	// 处理通知内容
+	fmt.Println(notifyReq.Summary)
+	fmt.Println(content)
+}
+
+func ExampleHandler_ParseNotifyRequest_no_model() {
+	var handler notify.Handler
+	var request *http.Request
+
+	content := make(map[string]interface{})
+	notifyReq, err := handler.ParseNotifyRequest(context.Background(), request, content)
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	// 处理通知内容
+	fmt.Println(notifyReq.Summary)
+	fmt.Println(content)
+}

+ 174 - 0
core/notify/notify.go

@@ -0,0 +1,174 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package notify 微信支付 API v3 Go SDK 商户通知处理库
+package notify
+
+import (
+	"bytes"
+	"context"
+	"crypto/aes"
+	"crypto/cipher"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/validators"
+)
+
+const rsaSignatureType = "WECHATPAY2-SHA256-RSA2048"
+const defaultSignatureType = rsaSignatureType
+const aeadAesGcmAlgorithm = "AEAD_AES_256_GCM"
+
+// Handler 通知处理器,使用前先设置验签和解密的算法套件
+type Handler struct {
+	cipherSuites map[string]CipherSuite
+}
+
+// CipherSuite 算法套件,包括验签和解密
+type CipherSuite struct {
+	signatureType string
+	validator     validators.WechatPayNotifyValidator
+	aeadAlgorithm string
+	aead          cipher.AEAD
+}
+
+// NewEmptyHandler 创建一个不包含算法套件的空通知处理器
+func NewEmptyHandler() *Handler {
+	h := &Handler{
+		cipherSuites: map[string]CipherSuite{},
+	}
+
+	return h
+}
+
+// AddCipherSuite 添加一个算法套件
+func (h *Handler) AddCipherSuite(cipherSuite CipherSuite) *Handler {
+	h.cipherSuites[cipherSuite.signatureType] = cipherSuite
+	return h
+}
+
+// AddRSAWithAESGCM 添加一个 RSA + AES-GCM 的算法套件
+func (h *Handler) AddRSAWithAESGCM(verifier auth.Verifier, aesgcm cipher.AEAD) *Handler {
+	v := CipherSuite{
+		signatureType: rsaSignatureType,
+		validator:     *validators.NewWechatPayNotifyValidator(verifier),
+		aeadAlgorithm: aeadAesGcmAlgorithm,
+		aead:          aesgcm,
+	}
+	return h.AddCipherSuite(v)
+}
+
+// ParseNotifyRequest 从 HTTP 请求(http.Request) 中解析 微信支付通知(notify.Request)
+func (h *Handler) ParseNotifyRequest(
+	ctx context.Context,
+	request *http.Request,
+	content interface{},
+) (*Request, error) {
+	signType := request.Header.Get("Wechatpay-Signature-Type")
+	if signType == "" {
+		signType = defaultSignatureType
+	}
+
+	suite, ok := h.cipherSuites[signType]
+	if !ok {
+		return nil, fmt.Errorf("unsupported Wechatpay-Signature-Type: %s", signType)
+	}
+
+	if err := suite.validator.Validate(ctx, request); err != nil {
+		return nil, fmt.Errorf("invalid notification, err: %v, request: %+v",
+			err, request)
+	}
+
+	body, err := getRequestBody(request)
+	if err != nil {
+		return nil, err
+	}
+
+	return processBody(suite, body, content)
+}
+
+func processBody(suite CipherSuite, body []byte, content interface{}) (*Request, error) {
+	ret := new(Request)
+	if err := json.Unmarshal(body, ret); err != nil {
+		return nil, fmt.Errorf("parse request body error: %v", err)
+	}
+
+	if ret.Resource.Algorithm != suite.aeadAlgorithm {
+		return nil, fmt.Errorf(
+			"possible invalid notification, resource.algorithm %s is not the configured algorithm %s",
+			ret.Resource.Algorithm,
+			suite.aeadAlgorithm)
+	}
+
+	plaintext, err := doAEADOpen(
+		suite.aead,
+		ret.Resource.Nonce,
+		ret.Resource.Ciphertext,
+		ret.Resource.AssociatedData,
+	)
+	if err != nil {
+		return ret, fmt.Errorf("%s decrypt error: %v", ret.Resource.Algorithm, err)
+	}
+
+	ret.Resource.Plaintext = plaintext
+
+	if err = json.Unmarshal([]byte(plaintext), &content); err != nil {
+		return ret, fmt.Errorf("unmarshal plaintext to content failed: %v", err)
+	}
+
+	return ret, nil
+}
+
+func doAEADOpen(c cipher.AEAD, nonce, ciphertext, additionalData string) (string, error) {
+	data, err := base64.StdEncoding.DecodeString(ciphertext)
+	if err != nil {
+		return "", err
+	}
+	plaintext, err := c.Open(
+		nil,
+		[]byte(nonce),
+		data,
+		[]byte(additionalData),
+	)
+	if err != nil {
+		return "", err
+	}
+
+	return string(plaintext), nil
+}
+
+func getRequestBody(request *http.Request) ([]byte, error) {
+	body, err := ioutil.ReadAll(request.Body)
+	if err != nil {
+		return nil, fmt.Errorf("read request body err: %v", err)
+	}
+
+	_ = request.Body.Close()
+	request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
+
+	return body, nil
+}
+
+// NewRSANotifyHandler 创建一个 RSA 的通知处理器,它包含 AES-GCM 解密能力
+func NewRSANotifyHandler(apiV3Key string, verifier auth.Verifier) (*Handler, error) {
+	c, err := aes.NewCipher([]byte(apiV3Key))
+	if err != nil {
+		return nil, err
+	}
+	aesgcm, err := cipher.NewGCM(c)
+	if err != nil {
+		return nil, err
+	}
+
+	return NewEmptyHandler().AddRSAWithAESGCM(verifier, aesgcm), nil
+}
+
+// NewNotifyHandler 创建通知处理器
+// Deprecated: Use NewRSANotifyHandler instead
+func NewNotifyHandler(apiV3Key string, verifier auth.Verifier) *Handler {
+	h, _ := NewRSANotifyHandler(apiV3Key, verifier)
+	return h
+}

+ 35 - 0
core/notify/notify_request.go

@@ -0,0 +1,35 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package notify
+
+import (
+	"net/http"
+	"time"
+)
+
+// Request 微信支付通知请求结构
+type Request struct {
+	ID           string             `json:"id"`
+	CreateTime   *time.Time         `json:"create_time"`
+	EventType    string             `json:"event_type"`
+	ResourceType string             `json:"resource_type"`
+	Resource     *EncryptedResource `json:"resource"`
+	Summary      string             `json:"summary"`
+
+	// 原始通知请求
+	RawRequest *http.Request
+}
+
+// EncryptedResource 微信支付通知请求中的内容
+type EncryptedResource struct {
+	Algorithm      string `json:"algorithm"`
+	Ciphertext     string `json:"ciphertext"`
+	AssociatedData string `json:"associated_data"`
+	Nonce          string `json:"nonce"`
+	OriginalType   string `json:"original_type"`
+
+	Plaintext string // Ciphertext 解密后内容
+}
+
+// ContentMap 将微信支付通知请求内容Json解密后的内容Map
+type ContentMap map[string]interface{}

+ 376 - 0
core/notify/notify_test.go

@@ -0,0 +1,376 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package notify
+
+import (
+	"bytes"
+	"context"
+	"crypto/cipher"
+	"crypto/x509"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/validators"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+
+	"github.com/agiledragon/gomonkey"
+)
+
+func Test_getRequestBody(t *testing.T) {
+	body := "fake req body"
+	bodyBuf := &bytes.Buffer{}
+	bodyBuf.WriteString(body)
+
+	req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1", bodyBuf)
+
+	bodyBytes, err := getRequestBody(req)
+	require.NoError(t, err)
+	assert.Equal(t, body, string(bodyBytes))
+
+	// Read Two times
+	bodyBytes, err = getRequestBody(req)
+	require.NoError(t, err)
+	assert.Equal(t, body, string(bodyBytes))
+
+	// Read Three times
+	bodyBytes, err = getRequestBody(req)
+	require.NoError(t, err)
+	assert.Equal(t, body, string(bodyBytes))
+}
+
+func Test_getRequestBodyReadAllError(t *testing.T) {
+	patch := gomonkey.ApplyFunc(
+		ioutil.ReadAll, func(r io.Reader) ([]byte, error) {
+			return nil, fmt.Errorf("read buf error")
+		},
+	)
+	defer patch.Reset()
+
+	body := "fake req body"
+	bodyBuf := &bytes.Buffer{}
+	bodyBuf.WriteString(body)
+
+	req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1", bodyBuf)
+
+	_, err := getRequestBody(req)
+	require.Error(t, err)
+}
+
+type contentType struct {
+	Mchid           *string    `json:"mchid"`
+	Appid           *string    `json:"appid"`
+	CreateTime      *time.Time `json:"create_time"`
+	OutContractCode *string    `json:"out_contract_code"`
+}
+
+func (o contentType) String() string {
+	ret := ""
+
+	if o.Mchid == nil {
+		ret += "Mchid:<nil>,"
+	} else {
+		ret += fmt.Sprintf("Mchid:%v,", *o.Mchid)
+	}
+
+	if o.Appid == nil {
+		ret += "Appid:<nil>,"
+	} else {
+		ret += fmt.Sprintf("Appid:%v,", *o.Appid)
+	}
+
+	if o.CreateTime == nil {
+		ret += "CreateTime:<nil>,"
+	} else {
+		ret += fmt.Sprintf("CreateTime:%v,", *o.CreateTime)
+	}
+
+	if o.OutContractCode == nil {
+		ret += "OutContractCode:<nil>,"
+	} else {
+		ret += fmt.Sprintf("OutContractCode:%v,", *o.OutContractCode)
+	}
+
+	return fmt.Sprintf("contentType{%s}", ret)
+}
+
+func TestHandler_ParseNotifyRequest(t *testing.T) {
+	patch := gomonkey.ApplyFunc(
+		time.Now, func() time.Time {
+			return time.Unix(1624523846, 0)
+		},
+	)
+	defer patch.Reset()
+
+	const (
+		mchAPIv3Key          = "testMchAPIv3Key0"
+		wechatPayCertificate = `-----BEGIN CERTIFICATE-----
+MIIDVzCCAj+gAwIBAgIJANfOWdH1ItcBMA0GCSqGSIb3DQEBCwUAMEIxCzAJBgNV
+BAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQg
+Q29tcGFueSBMdGQwHhcNMjEwNDI3MDg1NTIzWhcNMzEwNDI1MDg1NTIzWjBCMQsw
+CQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZh
+dWx0IENvbXBhbnkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
+2VCTd91fnUn73Xy9DLvt/V62TVxRTEEstVdeRaZ3B3leO0pldE806mXO4RwdHXag
+HQ4vGeZN0yqm++rDsGK+U3AH7kejyD2pXshNP9Cq5YwbptiLGtjcquw4HNxJQUOm
+DeJf2vg6byms9RUipiq4SzbJKqJFlUpbuIPDpSpWz10PYmyCNeDGUUK65E5h2B83
+4uxl1zNLYQCrkdBzb8oUxwYeP5a2DNxmjL5lsJML7DGr5znsevnoqGRwTm9fxCGf
+y8wus7hwKz6clt3Whmmda7UAdb1c08hEQFVRbF14AR73xbnd8N0obCWJPCbzMCtk
+aSef4FdEEgEXJiw0VAJT8wIDAQABo1AwTjAdBgNVHQ4EFgQUT1c7nd/SUO76HSoZ
+umNUJv1R5PwwHwYDVR0jBBgwFoAUT1c7nd/SUO76HSoZumNUJv1R5PwwDAYDVR0T
+BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAfTjxKRQMzNB/U6ZoCUS+BSNfa2Oh
+0plMN6ZuzwiVVZwg1jywvv5yv04koS7Pd4i9E4gt9ZBUQXlpq+A3oOCEEHNRR6b2
+kyazGRM7s0OP5X21WrbpSmKmU6K7hkfx30yYs08LVs/Q8DIhvaj1FCFeJzUCzYn/
+fHMq4tsbKO0dKAeydPM/nrUZBmaYQVKMVOORGLFjFKVO7JV6Kq/R86ouhjEPgJOe
+2xulNBUcjicqtZlBdEh/PWCYP2SpGVDclKm8jeo175T3EVAkdKzzmfpxtMmnMlmq
+cTJOU9TxuGvNASMtjj7pYIerTx+xgZDXEVBWFW9PjJ0TV06tCRsgSHItgg==
+-----END CERTIFICATE-----`
+		data = "{" +
+			"\"mchid\":\"1234567890\"," +
+			"\"appid\":\"054aa7d7a2a54ab5898df65bd96f001c\"," +
+			"\"create_time\":\"2020-06-30T12:12:00+08:00\"," +
+			"\"out_contract_code\":\"21640bdbd08e473e828f3206a2741c6e\"" +
+			"}"
+	)
+
+	headers := map[string]string{
+		"Content-Type":        "application/json",
+		"Wechatpay-Nonce":     "EcZ9Cmy4Xyx1i6RlJQzLcCyEqDa26NBz",
+		"Wechatpay-Timestamp": "1624523846",
+		"Wechatpay-Serial":    "D7CE59D1F522D701",
+		"Wechatpay-Signature": "tJHIiIS9eB2hAYstmAmbbD3ZE5LiIm/Ug5tuL4fC0YOFRWIHV39UFIZXC0e9Wl6lBu6sKvkqDkzpqzBsVHyXF" +
+			"lbYZTOQrVdG4b6LfTnK4mikv9++ixJMd3vTf2yCqvBkh98zs3Ds5zsYQakzbcwhmw4fMJs4nPLws28H0UW9FjDR//rxELLwXvV1VEA1I" +
+			"BLX70xptjL8hrfUjEE8kkry6yNJTHZRU8CAc7qHli2Ng1V1qb9ARbK8A3ThmFmPQvRGrapI/jS2laKKgYUmfdEdkNO6B2Cke5e8VTxY4" +
+			"06ArAmQ90GAihDwIcb16TQMnzCMBoutnwZKNiKRACrFmtxw2Q==",
+	}
+	body := "{" +
+		"\"id\":\"3119dfba-e649-5eec-ab1e-3412bc4d2e17\"," +
+		"\"create_time\":\"2021-06-24T16:37:26+08:00\"," +
+		"\"resource_type\":\"encrypt-resource\"," +
+		"\"event_type\":\"PAYSCORE.USER_OPEN_SERVICE\"," +
+		"\"summary\":\"签约成功\"," +
+		"\"resource\":{" +
+		"\"original_type\":\"payscore\"," +
+		"\"algorithm\":\"AEAD_AES_256_GCM\"," +
+		"\"ciphertext\":\"YDS3lKPaC4Y52Gf3uhft5qUBlIa8b428AWTtTauHQfQrRw+X1WpiuHIDy0vo1Vd6VEq67aVyqPdDYMkRVSDaZL3iZt" +
+		"tevRMOoPKMifozg6XPWjIZumks/GpT48lI4NizyeaqLBokNebthah3o1H76qSlO9NkDjp9bzmKLEYYH9TEklFUpsvPqOTOcgSLgh21YJXYR" +
+		"7dEBXFgRLiNIKRgO5JdXh1hccRUAlyVWxE54PXpnQ==\"," +
+		"\"associated_data\":\"payscore\"," +
+		"\"nonce\":\"Kj7QIyUiYx1q\"}" +
+		"}"
+
+	bodyBuf := &bytes.Buffer{}
+	bodyBuf.WriteString(body)
+
+	req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1", bodyBuf)
+
+	for key, value := range headers {
+		req.Header.Set(key, value)
+	}
+
+	cert, err := utils.LoadCertificate(wechatPayCertificate)
+	require.NoError(t, err)
+
+	handler, err := NewRSANotifyHandler(
+		mchAPIv3Key, verifiers.NewSHA256WithRSAVerifier(core.NewCertificateMapWithList([]*x509.Certificate{cert})),
+	)
+	assert.Nil(t, err)
+
+	content := new(contentType)
+
+	notifyReq, err := handler.ParseNotifyRequest(context.Background(), req, content)
+	require.NoError(t, err)
+
+	assert.Equal(t, "3119dfba-e649-5eec-ab1e-3412bc4d2e17", notifyReq.ID)
+	assert.Equal(t, "2021-06-24T16:37:26+08:00", notifyReq.CreateTime.Format(time.RFC3339))
+	assert.Equal(t, "encrypt-resource", notifyReq.ResourceType)
+	assert.Equal(t, "PAYSCORE.USER_OPEN_SERVICE", notifyReq.EventType)
+	assert.Equal(t, "签约成功", notifyReq.Summary)
+
+	assert.Equal(t, "payscore", notifyReq.Resource.AssociatedData)
+	assert.Equal(t, "AEAD_AES_256_GCM", notifyReq.Resource.Algorithm)
+	assert.Equal(t, "payscore", notifyReq.Resource.OriginalType)
+
+	assert.Equal(t, data, notifyReq.Resource.Plaintext)
+
+	assert.Equal(t, "1234567890", *content.Mchid)
+	assert.Equal(t, "054aa7d7a2a54ab5898df65bd96f001c", *content.Appid)
+	assert.Equal(t, "21640bdbd08e473e828f3206a2741c6e", *content.OutContractCode)
+	createTime, _ := time.Parse(time.RFC3339, "2020-06-30T12:12:00+08:00")
+	assert.Zero(t, content.CreateTime.Sub(createTime))
+}
+
+func TestHandler_ParseNotifyRequestValidateError(t *testing.T) {
+	patch := gomonkey.ApplyFunc(
+		(*validators.WechatPayNotifyValidator).Validate,
+		func(v *validators.WechatPayNotifyValidator, ctx context.Context, request *http.Request) error {
+			return fmt.Errorf("validate error")
+		},
+	)
+	defer patch.Reset()
+
+	handler, _ := NewRSANotifyHandler(
+		"testMchAPIv3Key0", verifiers.NewSHA256WithRSAVerifier(core.NewCertificateMapWithList(nil)),
+	)
+
+	req := httptest.NewRequest(
+		http.MethodGet, "http://127.0.0.1", ioutil.NopCloser(bytes.NewBuffer([]byte("fake req body"))),
+	)
+
+	content := make(map[string]interface{})
+	_, err := handler.ParseNotifyRequest(context.Background(), req, content)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "validate error")
+}
+
+func TestHandler_ParseNotifyRequest_getRequestBodyError(t *testing.T) {
+	patches := gomonkey.NewPatches()
+	defer patches.Reset()
+	patches.ApplyFunc(
+		(*validators.WechatPayNotifyValidator).Validate,
+		func(v *validators.WechatPayNotifyValidator, ctx context.Context, request *http.Request) error {
+			return nil
+		},
+	)
+
+	patches.ApplyFunc(
+		getRequestBody, func(request *http.Request) ([]byte, error) {
+			return nil, fmt.Errorf("getRequestBody error")
+		},
+	)
+
+	handler, _ := NewRSANotifyHandler(
+		"testMchAPIv3Key0", verifiers.NewSHA256WithRSAVerifier(core.NewCertificateMapWithList(nil)),
+	)
+	req := httptest.NewRequest(
+		http.MethodGet, "http://127.0.0.1", ioutil.NopCloser(bytes.NewBuffer([]byte("fake req body"))),
+	)
+
+	content := make(map[string]interface{})
+	_, err := handler.ParseNotifyRequest(context.Background(), req, content)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "getRequestBody error")
+}
+
+func TestHandler_ParseNotifyRequest_UnmarshalRequestError(t *testing.T) {
+	patches := gomonkey.NewPatches()
+	defer patches.Reset()
+	patches.ApplyFunc(
+		(*validators.WechatPayNotifyValidator).Validate,
+		func(v *validators.WechatPayNotifyValidator, ctx context.Context, request *http.Request) error {
+			return nil
+		},
+	)
+
+	patches.ApplyFunc(
+		getRequestBody, func(request *http.Request) ([]byte, error) {
+			return []byte("invalid json"), nil
+		},
+	)
+	// gonna cause unmarshal error
+
+	handler, _ := NewRSANotifyHandler(
+		"testMchAPIv3Key0", verifiers.NewSHA256WithRSAVerifier(core.NewCertificateMapWithList(nil)),
+	)
+	req := httptest.NewRequest(
+		http.MethodGet, "http://127.0.0.1", ioutil.NopCloser(bytes.NewBuffer([]byte("fake req body"))),
+	)
+
+	content := make(map[string]interface{})
+	_, err := handler.ParseNotifyRequest(context.Background(), req, content)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "parse request body error")
+}
+
+func TestHandler_ParseNotifyRequest_DecryptError(t *testing.T) {
+	patches := gomonkey.NewPatches()
+	defer patches.Reset()
+	patches.ApplyFunc(
+		(*validators.WechatPayNotifyValidator).Validate,
+		func(v *validators.WechatPayNotifyValidator, ctx context.Context, request *http.Request) error {
+			return nil
+		},
+	)
+
+	patches.ApplyFunc(
+		getRequestBody, func(request *http.Request) ([]byte, error) {
+			return []byte(`{"resource":{"algorithm":"AEAD_AES_256_GCM"}}`), nil
+		},
+	)
+
+	patches.ApplyFunc(
+		doAEADOpen, func(c cipher.AEAD, nonce, ciphertext, additionalData string) (plaintext string, err error) {
+			return "", fmt.Errorf("decrypt error")
+		},
+	)
+
+	handler, _ := NewRSANotifyHandler(
+		"testMchAPIv3Key0", verifiers.NewSHA256WithRSAVerifier(core.NewCertificateMapWithList(nil)),
+	)
+	req := httptest.NewRequest(
+		http.MethodGet, "http://127.0.0.1", ioutil.NopCloser(bytes.NewBuffer([]byte("fake req body"))),
+	)
+
+	content := make(map[string]interface{})
+	_, err := handler.ParseNotifyRequest(context.Background(), req, content)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "decrypt error")
+}
+
+func TestHandler_ParseNotifyRequest_UnmarshalContentError(t *testing.T) {
+	patches := gomonkey.NewPatches()
+	defer patches.Reset()
+	patches.ApplyFunc(
+		(*validators.WechatPayNotifyValidator).Validate,
+		func(v *validators.WechatPayNotifyValidator, ctx context.Context, request *http.Request) error {
+			return nil
+		},
+	)
+
+	patches.ApplyFunc(
+		getRequestBody, func(request *http.Request) ([]byte, error) {
+			return []byte(`{"resource":{"algorithm":"AEAD_AES_256_GCM"}}`), nil
+		},
+	)
+
+	patches.ApplyFunc(
+		doAEADOpen, func(c cipher.AEAD, nonce, ciphertext, additionalData string) (plaintext string, err error) {
+			return "invalid content", nil
+		},
+	)
+	// gonna cause unmarshal error
+
+	handler, _ := NewRSANotifyHandler(
+		"testMchAPIv3Key0", verifiers.NewSHA256WithRSAVerifier(core.NewCertificateMapWithList(nil)),
+	)
+	req := httptest.NewRequest(
+		http.MethodGet, "http://127.0.0.1", ioutil.NopCloser(bytes.NewBuffer([]byte("fake req body"))),
+	)
+
+	content := make(map[string]interface{})
+	_, err := handler.ParseNotifyRequest(context.Background(), req, content)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "unmarshal plaintext to content failed")
+}
+
+func TestHandler_processBody_InvalidAlgorithm(t *testing.T) {
+	v := CipherSuite{
+		signatureType: rsaSignatureType,
+		validator:     validators.WechatPayNotifyValidator{},
+		aeadAlgorithm: aeadAesGcmAlgorithm,
+		aead:          nil,
+	}
+
+	c := make(map[string]interface{})
+	_, err := processBody(v, []byte(`{"resource":{"algorithm":"AEAD_SM4_GCM"}}`), c)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "is not the configured algorithm")
+}

+ 119 - 0
core/option/auth_cipher_option.go

@@ -0,0 +1,119 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package option
+
+import (
+	"context"
+	"crypto/rsa"
+	"crypto/x509"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/signers"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/validators"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/cipher/ciphers"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/cipher/decryptors"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/cipher/encryptors"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
+)
+
+type withAuthCipherOption struct{ settings core.DialSettings }
+
+// Apply 设置 core.DialSettings 的 Signer、Validator 以及 Cipher
+func (w withAuthCipherOption) Apply(o *core.DialSettings) error {
+	o.Signer = w.settings.Signer
+	o.Validator = w.settings.Validator
+	o.Cipher = w.settings.Cipher
+	return nil
+}
+
+// WithWechatPayAuthCipher 一键初始化 Client,使其具备「签名/验签/敏感字段加解密」能力
+// Deprecated: 使用 WithWechatPayAutoAuthCipher 或 WithWechatPayPublicKeyAuthCipher 代替
+func WithWechatPayAuthCipher(
+	mchID string, certificateSerialNo string, privateKey *rsa.PrivateKey, certificateList []*x509.Certificate,
+) core.ClientOption {
+	certGetter := core.NewCertificateMapWithList(certificateList)
+	return withAuthCipherOption{
+		settings: core.DialSettings{
+			Signer: &signers.SHA256WithRSASigner{
+				MchID:               mchID,
+				PrivateKey:          privateKey,
+				CertificateSerialNo: certificateSerialNo,
+			},
+			Validator: validators.NewWechatPayResponseValidator(verifiers.NewSHA256WithRSAVerifier(certGetter)),
+			Cipher: ciphers.NewWechatPayCipher(
+				encryptors.NewWechatPayEncryptor(certGetter),
+				decryptors.NewWechatPayDecryptor(privateKey),
+			),
+		},
+	}
+}
+
+// WithWechatPayAutoAuthCipher 一键初始化 Client,使其具备「签名/验签/敏感字段加解密」能力。
+// 同时提供证书定时更新功能(因此需要提供 mchAPIv3Key 用于证书解密),不再需要本地提供平台证书
+func WithWechatPayAutoAuthCipher(
+	mchID string, certificateSerialNo string, privateKey *rsa.PrivateKey, mchAPIv3Key string,
+) core.ClientOption {
+	mgr := downloader.MgrInstance()
+
+	if !mgr.HasDownloader(context.Background(), mchID) {
+		err := mgr.RegisterDownloaderWithPrivateKey(
+			context.Background(), privateKey, certificateSerialNo, mchID, mchAPIv3Key,
+		)
+		if err != nil {
+			return core.ErrorOption{Error: err}
+		}
+	}
+
+	return WithWechatPayAutoAuthCipherUsingDownloaderMgr(mchID, certificateSerialNo, privateKey, mgr)
+}
+
+// WithWechatPayAutoAuthCipherUsingDownloaderMgr 一键初始化 Client,使其具备「签名/验签/敏感字段加解密」能力。
+// 需要使用者自行提供 CertificateDownloaderMgr 已实现平台证书的自动更新
+//
+// 【注意】本函数的能力与 WithWechatPayAutoAuthCipher 完全一致,除非有自行管理 CertificateDownloaderMgr 的需求,
+// 否则推荐直接使用 WithWechatPayAutoAuthCipher
+func WithWechatPayAutoAuthCipherUsingDownloaderMgr(
+	mchID string, certificateSerialNo string, privateKey *rsa.PrivateKey, mgr *downloader.CertificateDownloaderMgr,
+) core.ClientOption {
+	certVisitor := mgr.GetCertificateVisitor(mchID)
+	return withAuthCipherOption{
+		settings: core.DialSettings{
+			Signer: &signers.SHA256WithRSASigner{
+				MchID:               mchID,
+				CertificateSerialNo: certificateSerialNo,
+				PrivateKey:          privateKey,
+			},
+			Validator: validators.NewWechatPayResponseValidator(verifiers.NewSHA256WithRSAVerifier(certVisitor)),
+			Cipher: ciphers.NewWechatPayCipher(
+				encryptors.NewWechatPayEncryptor(certVisitor),
+				decryptors.NewWechatPayDecryptor(privateKey),
+			),
+		},
+	}
+}
+
+// WithWechatPayPublicKeyAuthCipher 一键初始化 Client,使其具备「签名/验签/敏感字段加解密」能力。
+// 使用微信支付提供的公钥验签
+func WithWechatPayPublicKeyAuthCipher(
+	mchID, certificateSerialNo string, privateKey *rsa.PrivateKey, publicKeyID string, publicKey *rsa.PublicKey,
+) core.ClientOption {
+	return withAuthCipherOption{
+		settings: core.DialSettings{
+			Signer: &signers.SHA256WithRSASigner{
+				MchID:               mchID,
+				CertificateSerialNo: certificateSerialNo,
+				PrivateKey:          privateKey,
+			},
+			Validator: validators.NewWechatPayResponseValidator(
+				verifiers.NewSHA256WithRSAPubkeyVerifier(
+					publicKeyID,
+					*publicKey,
+				)),
+			Cipher: ciphers.NewWechatPayCipher(
+				encryptors.NewWechatPayPubKeyEncryptor(publicKeyID, *publicKey),
+				decryptors.NewWechatPayDecryptor(privateKey),
+			),
+		},
+	}
+}

+ 4 - 0
core/option/doc.go

@@ -0,0 +1,4 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+// Package option 微信支付 API v3 Go SDK Client 初始化参数工具包,你可以使用其中的方法快速构建 core.Client 的初始化参数。
+package option

+ 120 - 0
core/option/option.go

@@ -0,0 +1,120 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package option
+
+import (
+	"crypto/rsa"
+	"crypto/x509"
+	"net/http"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/signers"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/validators"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/cipher"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/cipher/ciphers"
+)
+
+// region SignerOption
+
+// withSignerOption 为 Client 设置 Signer
+type withSignerOption struct {
+	Signer auth.Signer
+}
+
+// Apply 将配置添加到 core.DialSettings 中
+func (w withSignerOption) Apply(o *core.DialSettings) error {
+	o.Signer = w.Signer
+	return nil
+}
+
+// WithSigner 返回一个指定signer的ClientOption
+func WithSigner(signer auth.Signer) core.ClientOption {
+	return withSignerOption{Signer: signer}
+}
+
+// WithMerchantCredential 通过商户号、商户证书序列号、商户私钥构建一对 Credential/Signer,用于生成请求头中的 Authorization 信息
+func WithMerchantCredential(mchID, certificateSerialNo string, privateKey *rsa.PrivateKey) core.ClientOption {
+	signer := &signers.SHA256WithRSASigner{
+		MchID:               mchID,
+		PrivateKey:          privateKey,
+		CertificateSerialNo: certificateSerialNo,
+	}
+	return WithSigner(signer)
+}
+
+// endregion
+
+// region ValidatorOption
+
+// withValidatorOption 为 Client 设置 Validator
+type withValidatorOption struct {
+	Validator auth.Validator
+}
+
+// Apply 将配置添加到 core.DialSettings 中
+func (w withValidatorOption) Apply(o *core.DialSettings) error {
+	o.Validator = w.Validator
+	return nil
+}
+
+// WithVerifier 返回一个指定verifier的ClientOption,用于校验http response header
+func WithVerifier(verifier auth.Verifier) core.ClientOption {
+	validator := validators.NewWechatPayResponseValidator(verifier)
+	return withValidatorOption{Validator: validator}
+}
+
+// WithWechatPayCertificate 设置微信支付平台证书信息,返回一个指定validator的ClientOption,用于校验http response header
+func WithWechatPayCertificate(certificateList []*x509.Certificate) core.ClientOption {
+	verifier := verifiers.NewSHA256WithRSAVerifier(core.NewCertificateMapWithList(certificateList))
+	return WithVerifier(verifier)
+}
+
+// WithoutValidator 返回一个指定validator的ClientOption,不进行验签 用于下载证书和下载账单等不需要进行验签的接口
+func WithoutValidator() core.ClientOption {
+	return withValidatorOption{Validator: &validators.NullValidator{}}
+}
+
+// endregion
+
+// region HTTPClientOption
+
+// withHTTPClientOption 为 Client 设置 HTTPClient
+type withHTTPClientOption struct {
+	Client *http.Client
+}
+
+// Apply 将配置添加到 core.DialSettings 中
+func (w withHTTPClientOption) Apply(o *core.DialSettings) error {
+	o.HTTPClient = w.Client
+	return nil
+}
+
+// WithHTTPClient 返回一个指定网络通信为HttpClient的ClientOption,指定后使用用户自动创建的的http.client,如果用户不创建,则帮助用户
+// 创建一个默认的http.client
+func WithHTTPClient(client *http.Client) core.ClientOption {
+	return withHTTPClientOption{Client: client}
+}
+
+// endregion
+
+// region CipherOption
+
+// withCipherOption 为 Client 设置 Cipher
+type withCipherOption struct {
+	Cipher cipher.Cipher
+}
+
+// Apply 将配置添加到 core.DialSettings 中
+func (w withCipherOption) Apply(o *core.DialSettings) error {
+	o.Cipher = w.Cipher
+	return nil
+}
+
+// WithWechatPayCipher 返回一个为 Client 设置 WechatPayCipher 的 core.ClientOption
+func WithWechatPayCipher(encryptor cipher.Encryptor, decryptor cipher.Decryptor) core.ClientOption {
+	return withCipherOption{Cipher: ciphers.NewWechatPayCipher(encryptor, decryptor)}
+}
+
+// endregion

+ 30 - 0
core/settings.go

@@ -0,0 +1,30 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package core
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
+	"github.com/wechatpay-apiv3/wechatpay-go/core/cipher"
+)
+
+// DialSettings 微信支付 API v3 Go SDK core.Client 需要的配置信息
+type DialSettings struct {
+	HTTPClient *http.Client   // 自定义所使用的 HTTPClient 实例
+	Signer     auth.Signer    // 签名器
+	Validator  auth.Validator // 应答包签名校验器
+	Cipher     cipher.Cipher  // 敏感字段加解密套件
+}
+
+// Validate 校验请求配置是否有效
+func (ds *DialSettings) Validate() error {
+	if ds.Validator == nil {
+		return fmt.Errorf("validator is required for Client")
+	}
+	if ds.Signer == nil {
+		return fmt.Errorf("signer is required for Client")
+	}
+	return nil
+}

+ 40 - 0
core/type.go

@@ -0,0 +1,40 @@
+// Copyright 2021 Tencent Inc. All rights reserved.
+
+package core
+
+import "time"
+
+// Time 复制 time.Time 对象,并返回复制体的指针
+func Time(t time.Time) *time.Time {
+	return &t
+}
+
+// String 复制 string 对象,并返回复制体的指针
+func String(s string) *string {
+	return &s
+}
+
+// Bool 复制 bool 对象,并返回复制体的指针
+func Bool(b bool) *bool {
+	return &b
+}
+
+// Float64 复制 float64 对象,并返回复制体的指针
+func Float64(f float64) *float64 {
+	return &f
+}
+
+// Float32 复制 float32 对象,并返回复制体的指针
+func Float32(f float32) *float32 {
+	return &f
+}
+
+// Int64 复制 int64 对象,并返回复制体的指针
+func Int64(i int64) *int64 {
+	return &i
+}
+
+// Int32 复制 int64 对象,并返回复制体的指针
+func Int32(i int32) *int32 {
+	return &i
+}

+ 17 - 0
docs/cashcoupons/AvailableMerchantCollection.md

@@ -0,0 +1,17 @@
+# AvailableMerchantCollection
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**TotalCount** | **int64** | 可用商户总数量 | 
+**Data** | **[]string** | 可用商户列表 | [可选] 
+**Offset** | **int64** | 分页页码 | 
+**Limit** | **int64** | 分页大小 | 
+**StockId** | **string** | 批次号 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 17 - 0
docs/cashcoupons/AvailableSingleitemCollection.md

@@ -0,0 +1,17 @@
+# AvailableSingleitemCollection
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**TotalCount** | **int64** | 可用单品编码总数 | 
+**Data** | **[]string** | 可用单品编码 | [可选] 
+**Offset** | **int64** | 分页页码 | 
+**Limit** | **int64** | 分页大小 | 
+**StockId** | **string** | 批次号 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 39 - 0
docs/cashcoupons/BackgroundColor.md

@@ -0,0 +1,39 @@
+# BackgroundColor
+
+## 枚举
+
+
+* `COLOR010` (value: `"COLOR010"`)
+
+* `COLOR020` (value: `"COLOR020"`)
+
+* `COLOR030` (value: `"COLOR030"`)
+
+* `COLOR040` (value: `"COLOR040"`)
+
+* `COLOR050` (value: `"COLOR050"`)
+
+* `COLOR060` (value: `"COLOR060"`)
+
+* `COLOR070` (value: `"COLOR070"`)
+
+* `COLOR080` (value: `"COLOR080"`)
+
+* `COLOR081` (value: `"COLOR081"`)
+
+* `COLOR082` (value: `"COLOR082"`)
+
+* `COLOR090` (value: `"COLOR090"`)
+
+* `COLOR100` (value: `"COLOR100"`)
+
+* `COLOR101` (value: `"COLOR101"`)
+
+* `COLOR102` (value: `"COLOR102"`)
+
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 178 - 0
docs/cashcoupons/CallBackUrlApi.md

@@ -0,0 +1,178 @@
+# cashcoupons/CallBackUrlApi
+
+所有URI均基于微信支付 API 地址: *https://api.mch.weixin.qq.com*
+
+方法名 | HTTP 请求 | 描述
+------------- | ------------- | -------------
+[**QueryCallback**](#querycallback) | **Get** /v3/marketing/favor/callbacks | 查询代金券消息通知地址
+[**SetCallback**](#setcallback) | **Post** /v3/marketing/favor/callbacks | 设置代金券消息通知地址
+
+
+
+## QueryCallback
+
+> Callback QueryCallback(QueryCallbackRequest)
+
+查询代金券消息通知地址
+
+
+
+### 调用示例
+
+```go
+package main
+
+import (
+	"context"
+	"log"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/services/cashcoupons"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+func main() {
+	var (
+		mchID                      string = "190000****"                               // 商户号
+		mchCertificateSerialNumber string = "3775************************************" // 商户证书序列号
+		mchAPIv3Key                string = "2ab9****************************"         // 商户APIv3密钥
+	)
+
+	// 使用 utils 提供的函数从本地文件中加载商户私钥,商户私钥会用来生成请求的签名
+	mchPrivateKey, err := utils.LoadPrivateKeyWithPath("/path/to/merchant/apiclient_key.pem")
+	if err != nil {
+		log.Printf("load merchant private key error:%s", err)
+		return
+	}
+
+	ctx := context.Background()
+	// 使用商户私钥等初始化 client,并使它具有自动定时获取微信支付平台证书的能力
+	opts := []core.ClientOption{
+		option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
+	}
+	client, err := core.NewClient(ctx, opts...)
+	if err != nil {
+		log.Printf("new wechat pay client err:%s", err)
+		return
+	}
+
+	svc := cashcoupons.CallBackUrlApiService{Client: client}
+	resp, result, err := svc.QueryCallback(ctx,
+		cashcoupons.QueryCallbackRequest{
+			Mchid: core.String("9856888"),
+		},
+	)
+
+	if err != nil {
+		// 处理错误
+		log.Printf("call QueryCallback err:%s", err)
+	} else {
+		// 处理返回结果
+		log.Printf("status=%d resp=%s", result.Response.StatusCode, resp)
+	}
+}
+```
+
+### 参数列表
+参数名 | 参数类型 | 参数描述
+------------- | ------------- | -------------
+**ctx** | **context.Context** | Golang 上下文,可用于日志、请求取消、请求跟踪等功能|
+**req** | [**QueryCallbackRequest**](QueryCallbackRequest.md) | API `cashcoupons` 所定义的本接口需要的所有参数,包括`Path`/`Query`/`Body` 3类参数|
+
+### 返回结果
+Name | Type | Description
+------------- | ------------- | -------------
+**resp** | \*[**Callback**](Callback.md) | 结构化的接口返回结果
+**result** | **\*core.APIResult** | 本次 API 访问的请求与应答信息
+**err** | **error** | 本次 API 访问中发生的错误,当且仅当 API 失败时存在
+
+[\[返回顶部\]](#cashcouponscallbackurlapi)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回服务README\]](README.md)
+
+
+## SetCallback
+
+> SetCallbackResponse SetCallback(SetCallbackRequest)
+
+设置代金券消息通知地址
+
+
+
+### 调用示例
+
+```go
+package main
+
+import (
+	"context"
+	"log"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/services/cashcoupons"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+func main() {
+	var (
+		mchID                      string = "190000****"                               // 商户号
+		mchCertificateSerialNumber string = "3775************************************" // 商户证书序列号
+		mchAPIv3Key                string = "2ab9****************************"         // 商户APIv3密钥
+	)
+
+	// 使用 utils 提供的函数从本地文件中加载商户私钥,商户私钥会用来生成请求的签名
+	mchPrivateKey, err := utils.LoadPrivateKeyWithPath("/path/to/merchant/apiclient_key.pem")
+	if err != nil {
+		log.Printf("load merchant private key error:%s", err)
+		return
+	}
+
+	ctx := context.Background()
+	// 使用商户私钥等初始化 client,并使它具有自动定时获取微信支付平台证书的能力
+	opts := []core.ClientOption{
+		option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
+	}
+	client, err := core.NewClient(ctx, opts...)
+	if err != nil {
+		log.Printf("new wechat pay client err:%s", err)
+		return
+	}
+
+	svc := cashcoupons.CallBackUrlApiService{Client: client}
+	resp, result, err := svc.SetCallback(ctx,
+		cashcoupons.SetCallbackRequest{
+			Mchid:     core.String("9856888"),
+			NotifyUrl: core.String("https://pay.weixin.qq.com"),
+			Switch:    core.Bool(true),
+		},
+	)
+
+	if err != nil {
+		// 处理错误
+		log.Printf("call SetCallback err:%s", err)
+	} else {
+		// 处理返回结果
+		log.Printf("status=%d resp=%s", result.Response.StatusCode, resp)
+	}
+}
+```
+
+### 参数列表
+参数名 | 参数类型 | 参数描述
+------------- | ------------- | -------------
+**ctx** | **context.Context** | Golang 上下文,可用于日志、请求取消、请求跟踪等功能|
+**req** | [**SetCallbackRequest**](SetCallbackRequest.md) | API `cashcoupons` 所定义的本接口需要的所有参数,包括`Path`/`Query`/`Body` 3类参数|
+
+### 返回结果
+Name | Type | Description
+------------- | ------------- | -------------
+**resp** | \*[**SetCallbackResponse**](SetCallbackResponse.md) | 结构化的接口返回结果
+**result** | **\*core.APIResult** | 本次 API 访问的请求与应答信息
+**err** | **error** | 本次 API 访问中发生的错误,当且仅当 API 失败时存在
+
+[\[返回顶部\]](#cashcouponscallbackurlapi)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回服务README\]](README.md)
+

+ 14 - 0
docs/cashcoupons/Callback.md

@@ -0,0 +1,14 @@
+# Callback
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**NotifyUrl** | **string** | 通知地址 | 
+**Mchid** | **string** | 商户号 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 14 - 0
docs/cashcoupons/CardLimitation.md

@@ -0,0 +1,14 @@
+# CardLimitation
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**Name** | **string** | 当批次指定支付方式为银行卡且配置了指定银行卡信息,该字段必填,最多4个中文字符。并将在微信支付收银台中展示给用户。 | 
+**Bin** | **[]string** | 当批次指定支付方式为银行卡且配置了指定银行卡信息,该字段必填,按json格式。特殊规则:单个卡BIN的字符长度为[6,9],条目个数限制为[1,10] | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 25 - 0
docs/cashcoupons/Coupon.md

@@ -0,0 +1,25 @@
+# Coupon
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**StockCreatorMchid** | **string** | 微信为创建方商户分配的商户号 | 
+**StockId** | **string** | 批次id | 
+**CutToMessage** | [**CutTypeMsg**](CutTypeMsg.md) | 单品优惠特定信息 | [可选] 
+**CouponName** | **string** | 代金券名称 | 
+**Status** | **string** | 代金券状态:SENDED-可用,USED-已实扣,EXPIRED-已过期 | 
+**Description** | **string** | 代金券描述说明字段 | 
+**CreateTime** | **string** | 领券时间 | 
+**CouponType** | **string** | NORMAL-满减券;CUT_TO-减至券 | 
+**NoCash** | **bool** | true-是;false-否 | 
+**AvailableBeginTime** | **string** | 可用开始时间 | 
+**AvailableEndTime** | **string** | 可用结束时间 | 
+**Singleitem** | **bool** | TRUE-是;FALSE-否 | 
+**NormalCouponInformation** | [**FixedValueStockMsg**](FixedValueStockMsg.md) | 普通满减券面额、门槛信息 | [可选] 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 276 - 0
docs/cashcoupons/CouponApi.md

@@ -0,0 +1,276 @@
+# cashcoupons/CouponApi
+
+所有URI均基于微信支付 API 地址: *https://api.mch.weixin.qq.com*
+
+方法名 | HTTP 请求 | 描述
+------------- | ------------- | -------------
+[**ListCouponsByFilter**](#listcouponsbyfilter) | **Get** /v3/marketing/favor/users/{openid}/coupons | 根据过滤条件查询用户的券
+[**QueryCoupon**](#querycoupon) | **Get** /v3/marketing/favor/users/{openid}/coupons/{coupon_id} | 查询代金券详情
+[**SendCoupon**](#sendcoupon) | **Post** /v3/marketing/favor/users/{openid}/coupons | 发放指定批次的代金券
+
+
+
+## ListCouponsByFilter
+
+> CouponCollection ListCouponsByFilter(ListCouponsByFilterRequest)
+
+根据过滤条件查询用户的券
+
+
+
+### 调用示例
+
+```go
+package main
+
+import (
+	"context"
+	"log"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/services/cashcoupons"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+func main() {
+	var (
+		mchID                      string = "190000****"                               // 商户号
+		mchCertificateSerialNumber string = "3775************************************" // 商户证书序列号
+		mchAPIv3Key                string = "2ab9****************************"         // 商户APIv3密钥
+	)
+
+	// 使用 utils 提供的函数从本地文件中加载商户私钥,商户私钥会用来生成请求的签名
+	mchPrivateKey, err := utils.LoadPrivateKeyWithPath("/path/to/merchant/apiclient_key.pem")
+	if err != nil {
+		log.Printf("load merchant private key error:%s", err)
+		return
+	}
+
+	ctx := context.Background()
+	// 使用商户私钥等初始化 client,并使它具有自动定时获取微信支付平台证书的能力
+	opts := []core.ClientOption{
+		option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
+	}
+	client, err := core.NewClient(ctx, opts...)
+	if err != nil {
+		log.Printf("new wechat pay client err:%s", err)
+		return
+	}
+
+	svc := cashcoupons.CouponApiService{Client: client}
+	resp, result, err := svc.ListCouponsByFilter(ctx,
+		cashcoupons.ListCouponsByFilterRequest{
+			Openid:         core.String("Openid_example"),
+			Appid:          core.String("Appid_example"),
+			StockId:        core.String("9865000"),
+			Status:         core.String("USED"),
+			CreatorMchid:   core.String("9865002"),
+			SenderMchid:    core.String("9865001"),
+			AvailableMchid: core.String("9865000"),
+			Offset:         core.Int64(0),
+			Limit:          core.Int64(20),
+		},
+	)
+
+	if err != nil {
+		// 处理错误
+		log.Printf("call ListCouponsByFilter err:%s", err)
+	} else {
+		// 处理返回结果
+		log.Printf("status=%d resp=%s", result.Response.StatusCode, resp)
+	}
+}
+```
+
+### 参数列表
+参数名 | 参数类型 | 参数描述
+------------- | ------------- | -------------
+**ctx** | **context.Context** | Golang 上下文,可用于日志、请求取消、请求跟踪等功能|
+**req** | [**ListCouponsByFilterRequest**](ListCouponsByFilterRequest.md) | API `cashcoupons` 所定义的本接口需要的所有参数,包括`Path`/`Query`/`Body` 3类参数|
+
+### 返回结果
+Name | Type | Description
+------------- | ------------- | -------------
+**resp** | \*[**CouponCollection**](CouponCollection.md) | 结构化的接口返回结果
+**result** | **\*core.APIResult** | 本次 API 访问的请求与应答信息
+**err** | **error** | 本次 API 访问中发生的错误,当且仅当 API 失败时存在
+
+[\[返回顶部\]](#cashcouponscouponapi)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回服务README\]](README.md)
+
+
+## QueryCoupon
+
+> Coupon QueryCoupon(QueryCouponRequest)
+
+查询代金券详情
+
+
+
+### 调用示例
+
+```go
+package main
+
+import (
+	"context"
+	"log"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/services/cashcoupons"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+func main() {
+	var (
+		mchID                      string = "190000****"                               // 商户号
+		mchCertificateSerialNumber string = "3775************************************" // 商户证书序列号
+		mchAPIv3Key                string = "2ab9****************************"         // 商户APIv3密钥
+	)
+
+	// 使用 utils 提供的函数从本地文件中加载商户私钥,商户私钥会用来生成请求的签名
+	mchPrivateKey, err := utils.LoadPrivateKeyWithPath("/path/to/merchant/apiclient_key.pem")
+	if err != nil {
+		log.Printf("load merchant private key error:%s", err)
+		return
+	}
+
+	ctx := context.Background()
+	// 使用商户私钥等初始化 client,并使它具有自动定时获取微信支付平台证书的能力
+	opts := []core.ClientOption{
+		option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
+	}
+	client, err := core.NewClient(ctx, opts...)
+	if err != nil {
+		log.Printf("new wechat pay client err:%s", err)
+		return
+	}
+
+	svc := cashcoupons.CouponApiService{Client: client}
+	resp, result, err := svc.QueryCoupon(ctx,
+		cashcoupons.QueryCouponRequest{
+			CouponId: core.String("9856888"),
+			Appid:    core.String("Appid_example"),
+			Openid:   core.String("Openid_example"),
+		},
+	)
+
+	if err != nil {
+		// 处理错误
+		log.Printf("call QueryCoupon err:%s", err)
+	} else {
+		// 处理返回结果
+		log.Printf("status=%d resp=%s", result.Response.StatusCode, resp)
+	}
+}
+```
+
+### 参数列表
+参数名 | 参数类型 | 参数描述
+------------- | ------------- | -------------
+**ctx** | **context.Context** | Golang 上下文,可用于日志、请求取消、请求跟踪等功能|
+**req** | [**QueryCouponRequest**](QueryCouponRequest.md) | API `cashcoupons` 所定义的本接口需要的所有参数,包括`Path`/`Query`/`Body` 3类参数|
+
+### 返回结果
+Name | Type | Description
+------------- | ------------- | -------------
+**resp** | \*[**Coupon**](Coupon.md) | 结构化的接口返回结果
+**result** | **\*core.APIResult** | 本次 API 访问的请求与应答信息
+**err** | **error** | 本次 API 访问中发生的错误,当且仅当 API 失败时存在
+
+[\[返回顶部\]](#cashcouponscouponapi)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回服务README\]](README.md)
+
+
+## SendCoupon
+
+> SendCouponResponse SendCoupon(SendCouponRequest)
+
+发放指定批次的代金券
+
+
+
+### 调用示例
+
+```go
+package main
+
+import (
+	"context"
+	"log"
+
+	"github.com/wechatpay-apiv3/wechatpay-go/core"
+	"github.com/wechatpay-apiv3/wechatpay-go/services/cashcoupons"
+	"github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+func main() {
+	var (
+		mchID                      string = "190000****"                               // 商户号
+		mchCertificateSerialNumber string = "3775************************************" // 商户证书序列号
+		mchAPIv3Key                string = "2ab9****************************"         // 商户APIv3密钥
+	)
+
+	// 使用 utils 提供的函数从本地文件中加载商户私钥,商户私钥会用来生成请求的签名
+	mchPrivateKey, err := utils.LoadPrivateKeyWithPath("/path/to/merchant/apiclient_key.pem")
+	if err != nil {
+		log.Printf("load merchant private key error:%s", err)
+		return
+	}
+
+	ctx := context.Background()
+	// 使用商户私钥等初始化 client,并使它具有自动定时获取微信支付平台证书的能力
+	opts := []core.ClientOption{
+		option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
+	}
+	client, err := core.NewClient(ctx, opts...)
+	if err != nil {
+		log.Printf("new wechat pay client err:%s", err)
+		return
+	}
+
+	svc := cashcoupons.CouponApiService{Client: client}
+	resp, result, err := svc.SendCoupon(ctx,
+		cashcoupons.SendCouponRequest{
+			Openid:            core.String("Openid_example"),
+			StockId:           core.String("example_stock_id"),
+			OutRequestNo:      core.String("example_out_request_no"),
+			Appid:             core.String("example_appid"),
+			StockCreatorMchid: core.String("example_stock_creator_mchid"),
+			CouponValue:       core.Int64(1),
+			CouponMinimum:     core.Int64(1),
+		},
+	)
+
+	if err != nil {
+		// 处理错误
+		log.Printf("call SendCoupon err:%s", err)
+	} else {
+		// 处理返回结果
+		log.Printf("status=%d resp=%s", result.Response.StatusCode, resp)
+	}
+}
+```
+
+### 参数列表
+参数名 | 参数类型 | 参数描述
+------------- | ------------- | -------------
+**ctx** | **context.Context** | Golang 上下文,可用于日志、请求取消、请求跟踪等功能|
+**req** | [**SendCouponRequest**](SendCouponRequest.md) | API `cashcoupons` 所定义的本接口需要的所有参数,包括`Path`/`Query`/`Body` 3类参数|
+
+### 返回结果
+Name | Type | Description
+------------- | ------------- | -------------
+**resp** | \*[**SendCouponResponse**](SendCouponResponse.md) | 结构化的接口返回结果
+**result** | **\*core.APIResult** | 本次 API 访问的请求与应答信息
+**err** | **error** | 本次 API 访问中发生的错误,当且仅当 API 失败时存在
+
+[\[返回顶部\]](#cashcouponscouponapi)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回服务README\]](README.md)
+

+ 16 - 0
docs/cashcoupons/CouponCollection.md

@@ -0,0 +1,16 @@
+# CouponCollection
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**Data** | [**[]Coupon**](Coupon.md) | 结果集 | [可选] 
+**TotalCount** | **int64** | 查询结果总数 | 
+**Limit** | **int64** | 分页大小 | 
+**Offset** | **int64** | 分页页码 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 22 - 0
docs/cashcoupons/CouponRule.md

@@ -0,0 +1,22 @@
+# CouponRule
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**CouponAvailableTime** | [**FavorAvailableTime**](FavorAvailableTime.md) | 需要指定领取后延时生效可选填 | [可选] 
+**FixedNormalCoupon** | [**FixedValueStockMsg**](FixedValueStockMsg.md) | stock_type为NORMAL时必填 | [可选] 
+**GoodsTag** | **[]string** | 订单优惠标记 | [可选] 
+**TradeType** | [**[]TradeType**](TradeType.md) | 支付方式 | [可选] 
+**CombineUse** | **bool** | true-是;false-否 | [可选] 
+**AvailableItems** | **[]string** | 可核销商品编码 | [可选] 
+**UnavailableItems** | **[]string** | 不参与优惠商品编码 | [可选] 
+**AvailableMerchants** | **[]string** | 可核销商户号 | [可选] 
+**LimitCard** | [**CardLimitation**](CardLimitation.md) | 当批次指定支付方式为某张银行卡时才生效,可选的 | [可选] 
+**LimitPay** | **[]string** | 限定该批次的指定支付方式,如零钱、指定银行卡等,需填入支付方式编码, 条目个数限制为[1,1] 。当前支持的支付方式,及其编码枚举值,请参考该文档: https://docs.qq.com/sheet/DWGpMbWx3b1JCbldy?c&#x3D;E3A0A0  | [可选] 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 24 - 0
docs/cashcoupons/CreateCouponStockRequest.md

@@ -0,0 +1,24 @@
+# CreateCouponStockRequest
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**StockName** | **string** | 批次名称 | 
+**Comment** | **string** | 仅配置商户可见,用于自定义信息 | [可选] 
+**BelongMerchant** | **string** | 批次归属商户号 | 
+**AvailableBeginTime** | **string** | 批次开始时间 | 
+**AvailableEndTime** | **string** | 批次结束时间 | 
+**StockUseRule** | [**StockRule**](StockRule.md) | 批次使用规则 | 
+**PatternInfo** | [**PatternInfo**](PatternInfo.md) | 代金券详情页 | [可选] 
+**CouponUseRule** | [**CouponRule**](CouponRule.md) |  | 
+**NoCash** | **bool** | 是否无资金流,true-是;false-否 | 
+**StockType** | **string** | 批次类型,NORMAL-固定面额满减券批次;DISCOUNT-折扣券批次;EXCHAHGE-换购券批次;RANDOM-千人千面券批次 | 
+**OutRequestNo** | **string** | 商户创建批次凭据号(格式:商户id+日期+流水号),商户侧需保持唯一性 | 
+**ExtInfo** | **string** | 扩展属性字段,按json格式,暂时无需填写 | [可选] 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 14 - 0
docs/cashcoupons/CreateCouponStockResponse.md

@@ -0,0 +1,14 @@
+# CreateCouponStockResponse
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**StockId** | **string** | 批次号 | 
+**CreateTime** | **string** | 创建时间 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 14 - 0
docs/cashcoupons/CutTypeMsg.md

@@ -0,0 +1,14 @@
+# CutTypeMsg
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**SinglePriceMax** | **int64** | 可用优惠的商品最高单价,单位分 | 
+**CutToPrice** | **int64** | 减至后的优惠单价 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 15 - 0
docs/cashcoupons/DeductBalanceMethod.md

@@ -0,0 +1,15 @@
+# DeductBalanceMethod
+
+## 枚举
+
+
+* `BATCH_DEDUCT` (value: `"BATCH_DEDUCT"`)
+
+* `REALTIME_DEDUCT` (value: `"REALTIME_DEDUCT"`)
+
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 15 - 0
docs/cashcoupons/FavorAvailableTime.md

@@ -0,0 +1,15 @@
+# FavorAvailableTime
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**FixAvailableTime** | [**FixedAvailableTime**](FixedAvailableTime.md) | 固定时间段可用 | [可选] 
+**SecondDayAvailable** | **bool** | true-是;false-否 | [可选] 
+**AvailableTimeAfterReceive** | **int64** | 领取后有效时间,单位分钟 | [可选] 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 15 - 0
docs/cashcoupons/FixedAvailableTime.md

@@ -0,0 +1,15 @@
+# FixedAvailableTime
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**AvailableWeekDay** | **[]int64** | 0-周日;1-周一;以此类推 | [可选] 
+**BeginTime** | **int64** | 当天开始时间,单位秒 | 
+**EndTime** | **int64** | 当天结束时间,单位秒,默认为23点59分59秒 | [可选] 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 14 - 0
docs/cashcoupons/FixedValueStockMsg.md

@@ -0,0 +1,14 @@
+# FixedValueStockMsg
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**CouponAmount** | **int64** | 面额,单位分 | 
+**TransactionMinimum** | **int64** | 使用券金额门槛,单位分 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 15 - 0
docs/cashcoupons/FormFile.md

@@ -0,0 +1,15 @@
+# FormFile
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**Filename** | **string** |  | [可选] 
+**ContentType** | **string** |  | [可选] 
+**Content** | **string** |  | [可选] 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 14 - 0
docs/cashcoupons/ImageMeta.md

@@ -0,0 +1,14 @@
+# ImageMeta
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**Filename** | **string** | 商户上传的媒体图片的名称,商户自定义,必须以JPG、BMP、PNG为后缀。 | [可选] 
+**Sha256** | **string** | 图片文件的文件摘要,即对图片文件的二进制内容进行sha256计算得到的值。 | [可选] 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 17 - 0
docs/cashcoupons/JumpTarget.md

@@ -0,0 +1,17 @@
+# JumpTarget
+
+## 枚举
+
+
+* `PAYMENT_CODE` (value: `"PAYMENT_CODE"`)
+
+* `MINI_PROGRAM` (value: `"MINI_PROGRAM"`)
+
+* `DEFAULT_PAGE` (value: `"DEFAULT_PAGE"`)
+
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 16 - 0
docs/cashcoupons/ListAvailableMerchantsRequest.md

@@ -0,0 +1,16 @@
+# ListAvailableMerchantsRequest
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**Offset** | **int64** | 分页页码,最大1000 | 
+**Limit** | **int64** | 分页大小,最大50 | 
+**StockCreatorMchid** | **string** | 批次创建方商户号 | 
+**StockId** | **string** | 批次号 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 16 - 0
docs/cashcoupons/ListAvailableSingleitemsRequest.md

@@ -0,0 +1,16 @@
+# ListAvailableSingleitemsRequest
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**Offset** | **int64** | 分页页码,最大500 | 
+**Limit** | **int64** | 分页大小,最大100 | 
+**StockCreatorMchid** | **string** | 批次创建方商户号 | 
+**StockId** | **string** | 批次号 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 21 - 0
docs/cashcoupons/ListCouponsByFilterRequest.md

@@ -0,0 +1,21 @@
+# ListCouponsByFilterRequest
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**Openid** | **string** | 用户在商户appid 下的唯一标识 | 
+**Appid** | **string** | 微信为发券方商户分配的公众账号ID,接口传入的所有appid应该为公众号的appid(在mp.weixin.qq.com申请的),不能为APP的appid(在open.weixin.qq.com申请的)。 | 
+**StockId** | **string** | 批次号,是否指定批次号查询,填写available_mchid,该字段不生效 | [可选] 
+**Status** | **string** | 代金券状态:SENDED-可用,USED-已实扣,填写available_mchid,该字段不生效 | [可选] 
+**CreatorMchid** | **string** | 批次创建方商户号。创建批次的商户号,批次发放商户号,可用商户号三个参数,任意选填一个。 | [可选] 
+**SenderMchid** | **string** | 批次发放商户号。创建批次的商户号,批次发放商户号,可用商户号三个参数,任意选填一个。 | [可选] 
+**AvailableMchid** | **string** | 可用商户号。 创建批次的商户号,批次发放商户号,可用商户号三个参数,任意选填一个。 | [可选] 
+**Offset** | **int64** | 分页页码,默认0,填写available_mchid,该字段不生效 | [可选] 
+**Limit** | **int64** | 分页大小,默认20,填写available_mchid,该字段不生效 | [可选] 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 18 - 0
docs/cashcoupons/ListStocksRequest.md

@@ -0,0 +1,18 @@
+# ListStocksRequest
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**Offset** | **int64** | 页码从0开始,默认第0页 | 
+**Limit** | **int64** | 分页大小,最大10 | 
+**StockCreatorMchid** | **string** | 批次创建方商户号 | 
+**CreateStartTime** | **string** | 起始创建时间 | [可选] 
+**CreateEndTime** | **string** | 终止创建时间 | [可选] 
+**Status** | **string** | 批次状态: unactivated-未激活;audit-审核中;running-运行中;stoped-已停止;paused-暂停发放 | [可选] 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 14 - 0
docs/cashcoupons/MediaImageRequest.md

@@ -0,0 +1,14 @@
+# MediaImageRequest
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**File** | [**FormFile**](FormFile.md) | 将媒体图片进行二进制转换,得到的媒体图片二进制内容,在请求body中上传此二进制内容。媒体图片只支持JPG、BMP、PNG格式,文件大小不能超过2M。 | 
+**Meta** | [**ImageMeta**](ImageMeta.md) |  | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 13 - 0
docs/cashcoupons/MediaImageResponse.md

@@ -0,0 +1,13 @@
+# MediaImageResponse
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**MediaUrl** | **string** | 微信返回的媒体文件url地址。 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 15 - 0
docs/cashcoupons/ModifyAvailableMerchantRequest.md

@@ -0,0 +1,15 @@
+# ModifyAvailableMerchantRequest
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**StockCreatorMchid** | **string** | 批次创建方商户号 | 
+**AddMchidList** | **[]string** | 增加可用商户列表 | [可选] 
+**DeleteMchidList** | **[]string** | 删除可用商户列表 | [可选] 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 14 - 0
docs/cashcoupons/ModifyAvailableMerchantResponse.md

@@ -0,0 +1,14 @@
+# ModifyAvailableMerchantResponse
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**EffectTime** | **string** | 生效时间 | 
+**StockId** | **string** | 批次号 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 15 - 0
docs/cashcoupons/ModifyAvailableSingleitemRequest.md

@@ -0,0 +1,15 @@
+# ModifyAvailableSingleitemRequest
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**StockCreatorMchid** | **string** | 批次创建方商户号 | 
+**AddGoodsIdList** | **[]string** | 增加单品编码列表 | [可选] 
+**DeleteGoodsIdList** | **[]string** | 删除单品编码列表 | [可选] 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 14 - 0
docs/cashcoupons/ModifyAvailableSingleitemResponse.md

@@ -0,0 +1,14 @@
+# ModifyAvailableSingleitemResponse
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**EffectTime** | **string** | 生效时间 | 
+**StockId** | **string** | 批次号 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 15 - 0
docs/cashcoupons/ModifyStockBudgetRequest.md

@@ -0,0 +1,15 @@
+# ModifyStockBudgetRequest
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**StockCreatorMchid** | **string** | 批次创建方商户号 | 
+**TargetMaxAmount** | **int64** | 预算修改目标额度,单位分 | 
+**CurrentMaxAmount** | **int64** | 当前预算额度,单位分 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

+ 14 - 0
docs/cashcoupons/ModifyStockBudgetResponse.md

@@ -0,0 +1,14 @@
+# ModifyStockBudgetResponse
+
+## 属性列表
+
+名称 | 类型 | 描述 | 补充说明
+------------ | ------------- | ------------- | -------------
+**MaxAmount** | **int64** | 批次预算额度,单位分 | 
+**StockId** | **string** | 批次号 | 
+
+[\[返回类型列表\]](README.md#类型列表)
+[\[返回接口列表\]](README.md#接口列表)
+[\[返回服务README\]](README.md)
+
+

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff