Compare commits

..
8 Commits
Author SHA1 Message Date
wwek 8409760713 feat(kf): 获取客户基础信息支持 need_enter_session_context 及进入会话上下文返回 (#894) 2026-11-04 22:22:31 +08:00
ccfish ad48392a5b feat(miniprogram): 添加学生身份核验和通用API调用功能 (#893)
* feat: 添加 微信物流服务 /微信物流服务(商家查看) /消息组件

* fix lint issues

* fix: 查询运单详情信息返回结果结构

* 追加官方文档地址

* feat: 追加接口【微信物流服务 /微信物流服务(商家查看) /查询组件】

* fix: comments

* fix: indents

* feat(miniprogram): 添加学生身份核验和通用API调用功能

- 新增学生身份核验模块,支持快速获取学生身份接口
- 添加通用API调用功能,用于访问SDK尚未封装的小程序接口
- 实现GET/POST请求及响应解码的通用方法
- 在文档中补充学生身份相关API说明
2026-09-29 16:25:30 +08:00
GargantuaX 2bc22dc4ae feat: add aispeech dialog APIs (#887)
* feat: add aispeech dialog APIs

* test: fix aispeech lint failures

* test: satisfy aispeech interfacer lint
2026-07-20 14:10:56 +08:00
劣头andSuperX 0e34286d78 fix(external_user): 修复外部联系人结构体字段类型并格式化对齐 (#890)
将ExternalProfile字段从字符串类型修正为指针结构体类型,适配企业微信API返回的JSON对象格式;同时对齐所有结构体字段的JSON标签,提升代码可读性

Co-authored-by: SuperX <xiang.song@todreamer.com>
2026-06-23 14:44:44 +08:00
muzimu fb3009e337 fix: 修复memory cache并发问题 (#889) 2026-06-15 18:11:33 +08:00
wwek d34af7aeeb feat(virtualpayment): ✨ 添加 postJSONBytesContext 函数并更新相关调用 解决body签名不一致问题 (#886)
* fix typo

* feat(virtualpayment): ✨ 添加广告金相关接口及错误码

- 新增批量任务、退款、广告金充值等相关错误码
- 添加下载订单、查询商家账户余额、查询广告金发放记录等接口
- 实现投诉处理相关接口,包括获取投诉列表、投诉详情、协商历史等
- 完善上传媒体文件及获取签名头部的功能

* feat(virtualpayment): ✨ 优化请求地址组合逻辑,合并多个 case 语句

* 补充虚拟支付道具发货拼团字段

* fix(virtualpayment): 🐛 修正退款错误码注释拼写和商户订单号字段名称

* feat(virtualpayment): ✨ 添加 iOS Apple 支付退款问询事件及相关数据结构

* feat(virtualpayment): ✨ 优化 iOS Apple 支付退款问询事件的注释格式

* feat(virtualpayment): ✨ 添加 postJSONBytesContext 函数并更新相关调用

* fix(miniprogram): ShowSplashAd 传 false 时未序列化发送到微信 API

将 ShowSplashAd 从 bool 改为 *bool,解决 omitempty 导致 false 值
被当作零值省略的问题。微信 API 默认值为 true(展示广告),传 false
无法关闭封面广告。

Closes #882
2026-06-03 23:31:15 +08:00
wwek bfbfc844ce feat(virtualpayment): 虚拟支付对齐微信最新api接口文档 (#885)
* fix typo

* feat(virtualpayment): ✨ 添加广告金相关接口及错误码

- 新增批量任务、退款、广告金充值等相关错误码
- 添加下载订单、查询商家账户余额、查询广告金发放记录等接口
- 实现投诉处理相关接口,包括获取投诉列表、投诉详情、协商历史等
- 完善上传媒体文件及获取签名头部的功能

* feat(virtualpayment): ✨ 优化请求地址组合逻辑,合并多个 case 语句

* 补充虚拟支付道具发货拼团字段

* fix(virtualpayment): 🐛 修正退款错误码注释拼写和商户订单号字段名称

* feat(virtualpayment): ✨ 添加 iOS Apple 支付退款问询事件及相关数据结构

* feat(virtualpayment): ✨ 优化 iOS Apple 支付退款问询事件的注释格式
2026-05-22 01:00:39 +08:00
vvfanandniuweifan 269d93ea6f fix: avoid using empty jsapi ticket from cache (#883)
Co-authored-by: niuweifan <niuweifan@66nao.com>
2026-05-18 22:43:20 +08:00
32 changed files with 3103 additions and 72 deletions

No files matched your search

+100 -2
View File
@@ -1,5 +1,103 @@
# 智能对话
[官方文档](https://developers.weixin.qq.com/doc/aispeech/platform/INTERFACEDOCUMENT.html)
[官方文档](https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html)
## 快速入门
## 快速入门
```go
import (
"github.com/silenceper/wechat/v2"
"github.com/silenceper/wechat/v2/aispeech/config"
"github.com/silenceper/wechat/v2/aispeech/dialog"
"github.com/silenceper/wechat/v2/cache"
)
wc := wechat.NewWechat()
memory := cache.NewMemory()
ai := wc.GetAISpeech(&config.Config{
AppID: "xxx",
Token: "xxx",
AESKey: "xxx",
Account: "admin",
Cache: memory,
})
dialogClient := ai.GetDialog()
accessToken, err := dialogClient.GetAccessToken()
if err != nil {
return err
}
res, err := dialogClient.Query(&dialog.QueryRequest{
Query: "你好",
Env: "online",
UserID: "user-1",
})
```
## 简单问答导入与发布
```go
task, err := dialogClient.ImportJSON(&dialog.ImportJSONRequest{
Mode: 0,
Data: []dialog.BotIntent{{
Skill: "售前咨询",
Intent: "查询营业时间",
Disable: false,
Questions: []string{"你们几点开门", "营业时间是什么时候"},
Answers: []string{"我们的营业时间是周一至周五 9:00-18:00"},
}},
})
if err != nil {
return err
}
asyncResult, err := dialogClient.FetchAsync(&dialog.FetchAsyncRequest{
TaskID: task.TaskID,
})
if err != nil {
return err
}
publish, err := dialogClient.Publish()
if err != nil {
return err
}
progress, err := dialogClient.GetEffectiveProgress(&dialog.EffectiveProgressRequest{
Env: "online",
})
```
当前封装微信智能对话开放接口中“接入智能对话”的 6 个接口:
- `POST /v2/token`
- `POST /v2/bot/import/json`
- `POST /v2/async/fetch`
- `POST /v2/bot/publish`
- `POST /v2/bot/effective_progress`
- `POST /v2/bot/query`
## 真实接口验证
默认单元测试不会访问真实微信接口。如需验证 token 和 query 只读链路,可设置以下环境变量后运行:
```bash
AISPEECH_INTEGRATION=1
AISPEECH_APPID=xxx
AISPEECH_TOKEN=xxx
AISPEECH_AES_KEY=xxx
go test ./aispeech/dialog -run TestIntegrationAccessTokenAndQuery
```
如需验证导入、异步任务查询、发布、发布进度查询和命中回答的完整链路,可显式开启变更型测试。该测试会导入一个 `CodexSmokeTest` 临时问答并发布到线上机器人:
```bash
AISPEECH_INTEGRATION_MUTATION=1
AISPEECH_APPID=xxx
AISPEECH_TOKEN=xxx
AISPEECH_AES_KEY=xxx
go test ./aispeech/dialog -run TestIntegrationFullDialogFlow -v
```
+60
View File
@@ -0,0 +1,60 @@
package aispeech
import (
stdcontext "context"
"github.com/silenceper/wechat/v2/aispeech/config"
"github.com/silenceper/wechat/v2/aispeech/context"
"github.com/silenceper/wechat/v2/aispeech/dialog"
"github.com/silenceper/wechat/v2/credential"
)
// AISpeech 微信智能对话相关 API.
type AISpeech struct {
ctx *context.Context
dialog *dialog.Dialog
}
// NewAISpeech 实例化智能对话 API.
func NewAISpeech(cfg *config.Config) *AISpeech {
ctx := &context.Context{
Config: cfg,
AccessTokenContextHandle: dialog.NewAccessToken(cfg),
}
return &AISpeech{ctx: ctx}
}
// GetContext get Context.
func (a *AISpeech) GetContext() *context.Context {
return a.ctx
}
// SetAccessTokenHandle 自定义 access_token 获取方式.
func (a *AISpeech) SetAccessTokenHandle(accessTokenHandle credential.AccessTokenHandle) {
a.ctx.AccessTokenContextHandle = credential.AccessTokenCompatibleHandle{
AccessTokenHandle: accessTokenHandle,
}
}
// SetAccessTokenContextHandle 自定义 access_token 获取方式.
func (a *AISpeech) SetAccessTokenContextHandle(accessTokenContextHandle credential.AccessTokenContextHandle) {
a.ctx.AccessTokenContextHandle = accessTokenContextHandle
}
// GetAccessToken 获取 access token.
func (a *AISpeech) GetAccessToken() (string, error) {
return a.ctx.GetAccessToken()
}
// GetAccessTokenContext 获取 access token.
func (a *AISpeech) GetAccessTokenContext(ctx stdcontext.Context) (string, error) {
return a.ctx.GetAccessTokenContext(ctx)
}
// GetDialog 获取对话平台 API.
func (a *AISpeech) GetDialog() *dialog.Dialog {
if a.dialog == nil {
a.dialog = dialog.NewDialog(a.ctx)
}
return a.dialog
}
+61
View File
@@ -0,0 +1,61 @@
package aispeech
import (
stdcontext "context"
"testing"
"github.com/silenceper/wechat/v2/aispeech/config"
"github.com/silenceper/wechat/v2/cache"
)
type staticAccessToken struct {
token string
}
func (s staticAccessToken) GetAccessToken() (string, error) {
return s.token, nil
}
type staticAccessTokenContext struct {
token string
}
type contextTokenKey struct{}
func (s staticAccessTokenContext) GetAccessToken() (string, error) {
return s.GetAccessTokenContext(stdcontext.Background())
}
func (s staticAccessTokenContext) GetAccessTokenContext(ctx stdcontext.Context) (string, error) {
if v := ctx.Value(contextTokenKey{}); v != nil {
return v.(string), nil
}
return s.token, nil
}
func TestSetAccessTokenHandle(t *testing.T) {
ai := NewAISpeech(&config.Config{Cache: cache.NewMemory()})
ai.SetAccessTokenHandle(staticAccessToken{token: "custom-token"})
token, err := ai.GetAccessToken()
if err != nil {
t.Fatalf("GetAccessToken error: %v", err)
}
if token != "custom-token" {
t.Fatalf("bad token: %s", token)
}
}
func TestSetAccessTokenContextHandle(t *testing.T) {
ai := NewAISpeech(&config.Config{Cache: cache.NewMemory()})
ai.SetAccessTokenContextHandle(staticAccessTokenContext{token: "custom-token"})
ctx := stdcontext.WithValue(stdcontext.Background(), contextTokenKey{}, "context-token")
token, err := ai.GetAccessTokenContext(ctx)
if err != nil {
t.Fatalf("GetAccessTokenContext error: %v", err)
}
if token != "context-token" {
t.Fatalf("bad token: %s", token)
}
}
+23
View File
@@ -0,0 +1,23 @@
package config
import "github.com/silenceper/wechat/v2/cache"
const defaultBaseURL = "https://openaiapi.weixin.qq.com"
// Config for 微信智能对话.
type Config struct {
AppID string `json:"app_id"`
Token string `json:"token"`
AESKey string `json:"aes_key"`
Account string `json:"account"`
BaseURL string `json:"base_url"`
Cache cache.Cache
}
// GetBaseURL returns the API base URL.
func (cfg *Config) GetBaseURL() string {
if cfg.BaseURL == "" {
return defaultBaseURL
}
return cfg.BaseURL
}
+15
View File
@@ -0,0 +1,15 @@
package config
import "testing"
func TestGetBaseURL(t *testing.T) {
cfg := &Config{}
if cfg.GetBaseURL() != defaultBaseURL {
t.Fatalf("bad default base url: %s", cfg.GetBaseURL())
}
cfg.BaseURL = "http://example.com"
if cfg.GetBaseURL() != "http://example.com" {
t.Fatalf("bad custom base url: %s", cfg.GetBaseURL())
}
}
+12
View File
@@ -0,0 +1,12 @@
package context
import (
"github.com/silenceper/wechat/v2/aispeech/config"
"github.com/silenceper/wechat/v2/credential"
)
// Context struct
type Context struct {
*config.Config
credential.AccessTokenContextHandle
}
+116
View File
@@ -0,0 +1,116 @@
package dialog
import (
stdcontext "context"
"encoding/json"
"github.com/silenceper/wechat/v2/aispeech/encryptor"
)
// GetAccessToken 获取 access token.
func (d *Dialog) GetAccessToken() (string, error) {
return d.Context.GetAccessToken()
}
// GetAccessTokenContext 获取 access token.
func (d *Dialog) GetAccessTokenContext(ctx stdcontext.Context) (string, error) {
return d.Context.GetAccessTokenContext(ctx)
}
// ImportJSON 简单问答导入.
func (d *Dialog) ImportJSON(req *ImportJSONRequest) (*ImportJSONResponse, error) {
return d.ImportJSONContext(stdcontext.Background(), req)
}
// ImportJSONContext 简单问答导入.
func (d *Dialog) ImportJSONContext(ctx stdcontext.Context, req *ImportJSONRequest) (*ImportJSONResponse, error) {
var res ImportJSONResponse
requestID, err := d.postJSON(ctx, importJSONPath, req, &res, "AISpeechImportJSON")
res.RequestID = requestID
return &res, err
}
// Publish 发布机器人.
func (d *Dialog) Publish() (*PublishResponse, error) {
return d.PublishContext(stdcontext.Background())
}
// PublishContext 发布机器人.
func (d *Dialog) PublishContext(ctx stdcontext.Context) (*PublishResponse, error) {
var res PublishResponse
requestID, err := d.postEmpty(ctx, publishPath, &res, "AISpeechPublish")
res.RequestID = requestID
return &res, err
}
// GetEffectiveProgress 查询机器人发布进度.
func (d *Dialog) GetEffectiveProgress(req *EffectiveProgressRequest) (*EffectiveProgressResponse, error) {
return d.GetEffectiveProgressContext(stdcontext.Background(), req)
}
// GetEffectiveProgressContext 查询机器人发布进度.
func (d *Dialog) GetEffectiveProgressContext(ctx stdcontext.Context, req *EffectiveProgressRequest) (*EffectiveProgressResponse, error) {
var res EffectiveProgressResponse
requestID, err := d.postJSON(ctx, effectiveProgressPath, req, &res, "AISpeechGetEffectiveProgress")
res.RequestID = requestID
return &res, err
}
// FetchAsync 查询异步任务.
func (d *Dialog) FetchAsync(req *FetchAsyncRequest) (*FetchAsyncResponse, error) {
return d.FetchAsyncContext(stdcontext.Background(), req)
}
// FetchAsyncContext 查询异步任务.
func (d *Dialog) FetchAsyncContext(ctx stdcontext.Context, req *FetchAsyncRequest) (*FetchAsyncResponse, error) {
var res FetchAsyncResponse
requestID, err := d.postJSON(ctx, fetchAsyncPath, req, &res, "AISpeechFetchAsync")
res.RequestID = requestID
return &res, err
}
// Query 调用智能对话.
func (d *Dialog) Query(req *QueryRequest) (*QueryResponse, error) {
return d.QueryContext(stdcontext.Background(), req)
}
// QueryContext 调用智能对话.
func (d *Dialog) QueryContext(ctx stdcontext.Context, req *QueryRequest) (*QueryResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
encryptedBody, err := encryptor.Encrypt(d.AESKey, body)
if err != nil {
return nil, err
}
accessToken, err := d.GetAccessTokenContext(ctx)
if err != nil {
return nil, err
}
if accessToken == "" {
return nil, errEmptyAccessToken
}
response, err := post(ctx, d.Config, queryPath, []byte(encryptedBody), "text/plain", accessToken, "")
if err != nil {
return nil, err
}
plainResponse := response
if !json.Valid(response) {
plainResponse, err = encryptor.Decrypt(d.AESKey, string(response))
if err != nil {
return nil, err
}
}
var res QueryResponse
requestID, err := decodeResponse(plainResponse, &res, "AISpeechQuery")
if err != nil {
return nil, err
}
res.RequestID = requestID
if json.Valid([]byte(res.Answer)) {
res.RawAnswer = json.RawMessage(res.Answer)
}
return &res, nil
}
+120
View File
@@ -0,0 +1,120 @@
package dialog
import (
stdcontext "context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/silenceper/wechat/v2/aispeech/config"
"github.com/silenceper/wechat/v2/aispeech/context"
"github.com/silenceper/wechat/v2/aispeech/encryptor"
"github.com/silenceper/wechat/v2/util"
)
const (
// tokenPath 获取 AccessToken.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html
tokenPath = "/v2/token"
// importJSONPath 简单问答 JSON 导入.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/import.html
importJSONPath = "/v2/bot/import/json"
// fetchAsyncPath 异步任务查询.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/fetch.html
fetchAsyncPath = "/v2/async/fetch"
// publishPath 发布机器人.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/publish.html
publishPath = "/v2/bot/publish"
// effectiveProgressPath 查询机器人发布进度.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/progress.html
effectiveProgressPath = "/v2/bot/effective_progress"
// queryPath 调用智能对话.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/query.html
queryPath = "/v2/bot/query"
)
// Dialog 智能对话平台.
type Dialog struct {
*context.Context
}
// NewDialog init.
func NewDialog(ctx *context.Context) *Dialog {
return &Dialog{ctx}
}
func (d *Dialog) postJSON(ctx stdcontext.Context, path string, req interface{}, res interface{}, apiName string) (string, error) {
body, err := json.Marshal(req)
if err != nil {
return "", err
}
accessToken, err := d.GetAccessTokenContext(ctx)
if err != nil {
return "", err
}
if accessToken == "" {
return "", errEmptyAccessToken
}
response, err := post(ctx, d.Config, path, body, "application/json", accessToken, "")
if err != nil {
return "", err
}
return decodeResponse(response, res, apiName)
}
func (d *Dialog) postEmpty(ctx stdcontext.Context, path string, res interface{}, apiName string) (string, error) {
accessToken, err := d.GetAccessTokenContext(ctx)
if err != nil {
return "", err
}
if accessToken == "" {
return "", errEmptyAccessToken
}
response, err := post(ctx, d.Config, path, nil, "application/json", accessToken, "")
if err != nil {
return "", err
}
return decodeResponse(response, res, apiName)
}
func post(ctx stdcontext.Context, cfg *config.Config, path string, body []byte, contentType, accessToken, appID string) ([]byte, error) {
timestamp := time.Now().Unix()
nonce, err := randomString(16)
if err != nil {
return nil, err
}
requestID, err := randomString(32)
if err != nil {
return nil, err
}
header := map[string]string{
"request_id": requestID,
"timestamp": fmt.Sprintf("%d", timestamp),
"nonce": nonce,
"sign": encryptor.Sign(cfg.Token, timestamp, nonce, body),
"Content-Type": contentType,
}
if accessToken != "" {
header["X-OPENAI-TOKEN"] = accessToken
} else {
header["X-APPID"] = appID
}
return util.HTTPPostContext(ctx, buildURL(cfg.GetBaseURL(), path), body, header)
}
func buildURL(baseURL, path string) string {
return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
func randomString(length int) (string, error) {
buf := make([]byte, (length+1)/2)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf)[:length], nil
}
+422
View File
@@ -0,0 +1,422 @@
package dialog
import (
stdcontext "context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/silenceper/wechat/v2/aispeech/config"
aispeechContext "github.com/silenceper/wechat/v2/aispeech/context"
"github.com/silenceper/wechat/v2/aispeech/encryptor"
"github.com/silenceper/wechat/v2/cache"
)
const (
testAccessToken = "access-token"
testAccount = "admin"
testAESKey = "q1Os1ZMe0nG28KUEx9lg3HjK7V5QyXvi212fzsgDqgz"
testAppID = "appid"
testImportRequest = "import-rid"
testImportTaskID = "task-import"
testPublishTaskID = "task-publish"
testQuery = "hello"
testQueryAnswer = "hello answer"
testQueryRequestID = "query-rid"
testToken = "token"
testTokenRequestID = "token-rid"
)
type emptyAccessTokenHandle struct{}
func (emptyAccessTokenHandle) GetAccessToken() (string, error) {
return "", nil
}
func (emptyAccessTokenHandle) GetAccessTokenContext(ctx stdcontext.Context) (string, error) {
return "", nil
}
func TestAccessTokenCache(t *testing.T) {
var tokenRequests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenRequests++
body := readBody(t, r)
assertSign(t, r, testToken, body)
if r.Header.Get("X-APPID") != testAppID {
t.Fatalf("bad X-APPID: %s", r.Header.Get("X-APPID"))
}
_, _ = w.Write([]byte(accessTokenResponse("rid")))
}))
defer srv.Close()
ak := NewAccessToken(&config.Config{
AppID: testAppID,
Token: testToken,
Account: testAccount,
BaseURL: srv.URL,
Cache: cache.NewMemory(),
})
token, err := ak.GetAccessToken()
if err != nil {
t.Fatalf("GetAccessToken error: %v", err)
}
if token != testAccessToken {
t.Fatalf("bad token: %s", token)
}
token, err = ak.GetAccessToken()
if err != nil {
t.Fatalf("GetAccessToken second error: %v", err)
}
if token != testAccessToken {
t.Fatalf("bad token second: %s", token)
}
if tokenRequests != 1 {
t.Fatalf("token requests = %d, want 1", tokenRequests)
}
}
func TestAccessTokenCacheByAccount(t *testing.T) {
var tokenRequests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenRequests++
body := readBody(t, r)
assertSign(t, r, testToken, body)
var req AccessTokenRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("bad token body: %v", err)
}
_, _ = w.Write([]byte(accountAccessTokenResponse(req.Account)))
}))
defer srv.Close()
memory := cache.NewMemory()
cfgA := testDialogConfig(srv.URL)
cfgA.Account = "admin-a"
cfgA.Cache = memory
cfgB := testDialogConfig(srv.URL)
cfgB.Account = "admin-b"
cfgB.Cache = memory
tokenA, err := NewAccessToken(cfgA).GetAccessToken()
if err != nil {
t.Fatalf("GetAccessToken A error: %v", err)
}
tokenB, err := NewAccessToken(cfgB).GetAccessToken()
if err != nil {
t.Fatalf("GetAccessToken B error: %v", err)
}
if tokenA != "admin-a-token" || tokenB != "admin-b-token" {
t.Fatalf("bad tokens: %s %s", tokenA, tokenB)
}
if tokenRequests != 2 {
t.Fatalf("token requests = %d, want 2", tokenRequests)
}
}
func TestAccessTokenEmptyToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"rid","data":{"access_token":""}}`))
}))
defer srv.Close()
ak := NewAccessToken(&config.Config{
AppID: testAppID,
Token: testToken,
BaseURL: srv.URL,
Cache: cache.NewMemory(),
})
if _, err := ak.GetAccessToken(); err == nil {
t.Fatal("GetAccessToken should return error")
}
}
func TestDialogAPIs(t *testing.T) {
srv := newDialogAPITestServer(t)
defer srv.Close()
d := newTestDialog(srv.URL)
assertDialogImportJSON(t, d)
assertDialogPublish(t, d)
assertDialogProgress(t, d)
assertDialogFetchAsync(t, d)
assertDialogQuery(t, d)
}
func TestDialogEmptyAccessToken(t *testing.T) {
d := NewDialog(&aispeechContext.Context{
Config: &config.Config{
AESKey: testAESKey,
},
AccessTokenContextHandle: emptyAccessTokenHandle{},
})
if _, err := d.ImportJSON(&ImportJSONRequest{}); err == nil {
t.Fatal("ImportJSON should return error")
}
if _, err := d.Publish(); err == nil {
t.Fatal("Publish should return error")
}
if _, err := d.Query(&QueryRequest{}); err == nil {
t.Fatal("Query should return error")
}
}
func TestDialogAPIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case tokenPath:
_, _ = w.Write([]byte(accessTokenResponse(testTokenRequestID)))
case importJSONPath:
_, _ = w.Write([]byte(`{"code":210202,"msg":"forbidden","request_id":"import-rid","data":{"reason":"forbidden"}}`))
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer srv.Close()
d := newTestDialog(srv.URL)
_, err := d.ImportJSON(&ImportJSONRequest{})
if err == nil {
t.Fatal("ImportJSON should return error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("ImportJSON error should be *APIError but %T", err)
}
if apiErr.Code != 210202 || apiErr.Msg != "forbidden" || apiErr.RequestID != testImportRequest {
t.Fatalf("bad api error: %+v", apiErr)
}
if string(apiErr.Data) != `{"reason":"forbidden"}` {
t.Fatalf("bad api error data: %s", apiErr.Data)
}
}
func TestQueryPlainJSONError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case tokenPath:
_, _ = w.Write([]byte(accessTokenResponse(testTokenRequestID)))
case queryPath:
_, _ = w.Write([]byte(`{"code":110002,"msg":"bad param","request_id":"query-rid"}`))
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer srv.Close()
d := newTestDialog(srv.URL)
_, err := d.Query(&QueryRequest{Query: testQuery})
if err == nil {
t.Fatal("Query should return error")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("Query error should be *APIError but %T", err)
}
if apiErr.Code != 110002 || apiErr.RequestID != testQueryRequestID {
t.Fatalf("bad api error: %+v", apiErr)
}
}
func newDialogAPITestServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := readBody(t, r)
assertSign(t, r, testToken, body)
switch r.URL.Path {
case tokenPath:
handleDialogToken(t, w, r)
case importJSONPath:
handleDialogImport(t, w, r, body)
case publishPath:
handleDialogPublish(t, w, r, body)
case effectiveProgressPath:
assertToken(t, r)
_, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"progress-rid","data":{"end_time":"","progress":100,"status":1}}`))
case fetchAsyncPath:
assertToken(t, r)
_, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"fetch-rid","data":{"state":2,"msg":"","progress":100,"start":1,"end":2,"url":"","success_skill_info":[{"id":1,"name":"AAA","intents":[{"id":2,"name":"BBB"}]}]}}`))
case queryPath:
handleDialogQuery(t, w, r, body)
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
}
func handleDialogToken(t *testing.T, w io.Writer, r *http.Request) {
t.Helper()
if r.Header.Get("X-APPID") != testAppID {
t.Fatalf("bad X-APPID: %s", r.Header.Get("X-APPID"))
}
_, _ = w.Write([]byte(accessTokenResponse(testTokenRequestID)))
}
func handleDialogImport(t *testing.T, w io.Writer, r *http.Request, body []byte) {
t.Helper()
assertToken(t, r)
var req ImportJSONRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("bad import body: %v", err)
}
if len(req.Data) != 1 || req.Data[0].Skill != "pre-sale" {
t.Fatalf("bad import request: %+v", req)
}
_, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"import-rid","data":{"task_id":"task-import"}}`))
}
func handleDialogPublish(t *testing.T, w io.Writer, r *http.Request, body []byte) {
t.Helper()
assertToken(t, r)
if len(body) != 0 {
t.Fatalf("publish body should be empty: %s", body)
}
_, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"publish-rid","data":{"task_id":"task-publish"}}`))
}
func handleDialogQuery(t *testing.T, w io.Writer, r *http.Request, body []byte) {
t.Helper()
assertToken(t, r)
if !strings.HasPrefix(r.Header.Get("Content-Type"), "text/plain") {
t.Fatalf("bad content type: %s", r.Header.Get("Content-Type"))
}
plain, err := encryptor.Decrypt(testAESKey, string(body))
if err != nil {
t.Fatalf("decrypt query body error: %v", err)
}
var req QueryRequest
if err = json.Unmarshal(plain, &req); err != nil {
t.Fatalf("bad query body: %v", err)
}
if req.Query != testQuery {
t.Fatalf("bad query: %+v", req)
}
_, _ = w.Write([]byte(encryptQueryResponse(t)))
}
func assertDialogImportJSON(t *testing.T, d *Dialog) {
t.Helper()
res, err := d.ImportJSON(&ImportJSONRequest{
Mode: 0,
Data: []BotIntent{{
Skill: "pre-sale",
Intent: "business-hours",
Disable: false,
Questions: []string{"when are you open"},
Answers: []string{"9:00-18:00"},
}},
})
if err != nil || res.TaskID != testImportTaskID || res.RequestID != testImportRequest {
t.Fatalf("ImportJSON = %+v, %v", res, err)
}
}
func assertDialogPublish(t *testing.T, d *Dialog) {
t.Helper()
res, err := d.Publish()
if err != nil || res.TaskID != testPublishTaskID {
t.Fatalf("Publish = %+v, %v", res, err)
}
}
func assertDialogProgress(t *testing.T, d *Dialog) {
t.Helper()
res, err := d.GetEffectiveProgress(&EffectiveProgressRequest{Env: "online"})
if err != nil || res.Progress != 100 {
t.Fatalf("GetEffectiveProgress = %+v, %v", res, err)
}
}
func assertDialogFetchAsync(t *testing.T, d *Dialog) {
t.Helper()
res, err := d.FetchAsync(&FetchAsyncRequest{TaskID: testImportTaskID})
if err != nil || res.State != 2 || len(res.SuccessSkillInfo) != 1 {
t.Fatalf("FetchAsync = %+v, %v", res, err)
}
}
func assertDialogQuery(t *testing.T, d *Dialog) {
t.Helper()
res, err := d.Query(&QueryRequest{Query: testQuery, Env: "online"})
if err != nil || res.Answer != testQueryAnswer || res.RequestID != testQueryRequestID {
t.Fatalf("Query = %+v, %v", res, err)
}
}
func newTestDialog(baseURL string) *Dialog {
cfg := testDialogConfig(baseURL)
return NewDialog(&aispeechContext.Context{
Config: cfg,
AccessTokenContextHandle: NewAccessToken(cfg),
})
}
func testDialogConfig(baseURL string) *config.Config {
return &config.Config{
AppID: testAppID,
Token: testToken,
AESKey: testAESKey,
Account: testAccount,
BaseURL: baseURL,
Cache: cache.NewMemory(),
}
}
func accountAccessTokenResponse(account string) string {
return `{"code":0,"msg":"success","request_id":"rid","data":{"access_token":"` + account + `-token"}}`
}
func accessTokenResponse(requestID string) string {
return `{"code":0,"msg":"success","request_id":"` + requestID + `","data":{"access_token":"` + testAccessToken + `"}}`
}
func encryptQueryResponse(t *testing.T) string {
t.Helper()
cipherText, err := encryptor.Encrypt(testAESKey, []byte(queryResponse()))
if err != nil {
t.Fatalf("encrypt response error: %v", err)
}
return cipherText
}
func queryResponse() string {
return `{"code":0,"msg":"success","request_id":"query-rid","data":{"answer":"hello answer","answer_type":"text","skill_name":"skill","intent_name":"intent","msg_id":"msg","status":"FAQ","slots":[{"name":"n","value":"v","norm":"v"}]}}`
}
func readBody(t *testing.T, r *http.Request) []byte {
t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body error: %v", err)
}
return body
}
func assertToken(t *testing.T, r *http.Request) {
t.Helper()
if r.Header.Get("X-OPENAI-TOKEN") != testAccessToken {
t.Fatalf("bad X-OPENAI-TOKEN: %s", r.Header.Get("X-OPENAI-TOKEN"))
}
}
func assertSign(t *testing.T, r *http.Request, token string, body []byte) {
t.Helper()
timestamp, err := strconv.ParseInt(r.Header.Get("timestamp"), 10, 64)
if err != nil {
t.Fatalf("bad timestamp: %v", err)
}
want := encryptor.Sign(token, timestamp, r.Header.Get("nonce"), body)
if got := r.Header.Get("sign"); got != want {
t.Fatalf("bad sign: got %s want %s body %s", got, want, body)
}
}
+186
View File
@@ -0,0 +1,186 @@
package dialog
import (
"os"
"testing"
"time"
"github.com/silenceper/wechat/v2/aispeech/config"
aispeechContext "github.com/silenceper/wechat/v2/aispeech/context"
"github.com/silenceper/wechat/v2/cache"
)
func TestIntegrationAccessTokenAndQuery(t *testing.T) {
if os.Getenv("AISPEECH_INTEGRATION") != "1" {
t.Skip("set AISPEECH_INTEGRATION=1 to run real aispeech API test")
}
cfg := &config.Config{
AppID: os.Getenv("AISPEECH_APPID"),
Token: os.Getenv("AISPEECH_TOKEN"),
AESKey: os.Getenv("AISPEECH_AES_KEY"),
Cache: cache.NewMemory(),
}
if cfg.AppID == "" || cfg.Token == "" || cfg.AESKey == "" {
t.Fatal("AISPEECH_APPID, AISPEECH_TOKEN and AISPEECH_AES_KEY are required")
}
d := NewDialog(&aispeechContext.Context{
Config: cfg,
AccessTokenContextHandle: NewAccessToken(cfg),
})
accessToken, err := d.GetAccessToken()
if err != nil {
t.Fatalf("GetAccessToken error: %v", err)
}
if accessToken == "" {
t.Fatal("access token is empty")
}
res, err := d.Query(&QueryRequest{
Query: "你好",
Env: "online",
UserID: "gowechat-integration-test",
})
if err != nil {
t.Fatalf("Query error: %v", err)
}
if res.RequestID == "" {
t.Fatal("query request_id is empty")
}
}
func TestIntegrationFullDialogFlow(t *testing.T) {
if os.Getenv("AISPEECH_INTEGRATION_MUTATION") != "1" {
t.Skip("set AISPEECH_INTEGRATION_MUTATION=1 to run import/publish/query real aispeech API test")
}
d := newIntegrationDialog(t)
now := time.Now().Unix()
question := "codex-smoke-test-question-" + time.Unix(now, 0).Format("20060102150405")
answer := "codex-smoke-test-answer-" + time.Unix(now, 0).Format("20060102150405")
importRes, err := d.ImportJSON(&ImportJSONRequest{
Mode: 0,
Data: []BotIntent{{
Skill: "CodexSmokeTest",
Intent: question,
Disable: false,
Questions: []string{question},
Answers: []string{answer},
}},
})
if err != nil {
t.Fatalf("ImportJSON error: %v", err)
}
if importRes.TaskID == "" {
t.Fatal("ImportJSON task_id is empty")
}
t.Logf("ImportJSON request_id=%s task_id=%s", importRes.RequestID, importRes.TaskID)
importTask := waitAsyncTask(t, d, importRes.TaskID, 2*time.Minute)
if importTask.State != 2 {
t.Fatalf("import task failed: state=%d msg=%s progress=%d", importTask.State, importTask.Msg, importTask.Progress)
}
t.Logf("FetchAsync import request_id=%s state=%d progress=%d", importTask.RequestID, importTask.State, importTask.Progress)
publishRes, err := d.Publish()
if err != nil {
t.Fatalf("Publish error: %v", err)
}
if publishRes.TaskID == "" {
t.Fatal("Publish task_id is empty")
}
t.Logf("Publish request_id=%s task_id=%s", publishRes.RequestID, publishRes.TaskID)
progress := waitEffectiveProgress(t, d, "online", 3*time.Minute)
if progress.Status != 1 {
t.Fatalf("publish progress failed: status=%d progress=%d", progress.Status, progress.Progress)
}
t.Logf("GetEffectiveProgress request_id=%s status=%d progress=%d", progress.RequestID, progress.Status, progress.Progress)
queryRes := waitQueryAnswer(t, d, question, answer, 2*time.Minute)
t.Logf("Query request_id=%s status=%s answer_type=%s answer=%s", queryRes.RequestID, queryRes.Status, queryRes.AnswerType, queryRes.Answer)
}
func newIntegrationDialog(t *testing.T) *Dialog {
t.Helper()
cfg := &config.Config{
AppID: os.Getenv("AISPEECH_APPID"),
Token: os.Getenv("AISPEECH_TOKEN"),
AESKey: os.Getenv("AISPEECH_AES_KEY"),
Account: os.Getenv("AISPEECH_ACCOUNT"),
Cache: cache.NewMemory(),
}
if cfg.AppID == "" || cfg.Token == "" || cfg.AESKey == "" {
t.Fatal("AISPEECH_APPID, AISPEECH_TOKEN and AISPEECH_AES_KEY are required")
}
return NewDialog(&aispeechContext.Context{
Config: cfg,
AccessTokenContextHandle: NewAccessToken(cfg),
})
}
func waitAsyncTask(t *testing.T, d *Dialog, taskID string, timeout time.Duration) *FetchAsyncResponse {
t.Helper()
deadline := time.Now().Add(timeout)
for {
res, err := d.FetchAsync(&FetchAsyncRequest{TaskID: taskID})
if err != nil {
t.Fatalf("FetchAsync error: %v", err)
}
if res.State == 2 || res.State == 3 {
return res
}
if time.Now().After(deadline) {
t.Fatalf("FetchAsync timeout: state=%d progress=%d msg=%s", res.State, res.Progress, res.Msg)
}
time.Sleep(3 * time.Second)
}
}
func waitEffectiveProgress(t *testing.T, d *Dialog, env string, timeout time.Duration) *EffectiveProgressResponse {
t.Helper()
deadline := time.Now().Add(timeout)
for {
res, err := d.GetEffectiveProgress(&EffectiveProgressRequest{Env: env})
if err != nil {
t.Fatalf("GetEffectiveProgress error: %v", err)
}
if res.Status == 1 || res.Status == 2 {
return res
}
if time.Now().After(deadline) {
t.Fatalf("GetEffectiveProgress timeout: status=%d progress=%d", res.Status, res.Progress)
}
time.Sleep(5 * time.Second)
}
}
func waitQueryAnswer(t *testing.T, d *Dialog, question, answer string, timeout time.Duration) *QueryResponse {
t.Helper()
deadline := time.Now().Add(timeout)
for {
res, err := d.Query(&QueryRequest{
Query: question,
Env: "online",
UserID: "gowechat-full-integration-test",
})
if err != nil {
t.Fatalf("Query error: %v", err)
}
if res.Answer == answer {
return res
}
if time.Now().After(deadline) {
t.Fatalf("Query timeout: status=%s answer=%s want=%s", res.Status, res.Answer, answer)
}
time.Sleep(5 * time.Second)
}
}
+48
View File
@@ -0,0 +1,48 @@
package dialog
// AccessTokenRequest 获取 access token 请求.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html
type AccessTokenRequest struct {
Account string `json:"account,omitempty"`
}
// ImportJSONRequest 简单问答导入请求.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/import.html
type ImportJSONRequest struct {
Mode int `json:"mode"`
Data []BotIntent `json:"data"`
}
// BotIntent 简单问答意图.
type BotIntent struct {
Skill string `json:"skill"`
Intent string `json:"intent"`
Threshold string `json:"threshold,omitempty"`
Disable bool `json:"disable"`
Questions []string `json:"questions"`
Answers []string `json:"answers"`
}
// EffectiveProgressRequest 发布进度查询请求.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/progress.html
type EffectiveProgressRequest struct {
Env string `json:"env"`
}
// FetchAsyncRequest 异步任务查询请求.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/fetch.html
type FetchAsyncRequest struct {
TaskID string `json:"task_id"`
}
// QueryRequest 调用智能对话请求.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/query.html
type QueryRequest struct {
Query string `json:"query"`
Env string `json:"env,omitempty"`
FirstPrioritySkills []string `json:"first_priority_skills,omitempty"`
SecondPrioritySkills []string `json:"second_priority_skills,omitempty"`
UserName string `json:"user_name,omitempty"`
Avatar string `json:"avatar,omitempty"`
UserID string `json:"userid,omitempty"`
}
+137
View File
@@ -0,0 +1,137 @@
package dialog
import (
"encoding/json"
"fmt"
)
type apiResponse struct {
RequestID string `json:"request_id"`
Code int64 `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
// APIError 智能对话接口错误.
type APIError struct {
APIName string
Code int64
Msg string
RequestID string
Data json.RawMessage
}
func (e *APIError) Error() string {
return fmt.Sprintf("%s Error , code=%d , msg=%s , request_id=%s", e.APIName, e.Code, e.Msg, e.RequestID)
}
type accessTokenData struct {
AccessToken string `json:"access_token"`
}
// ImportJSONResponse 简单问答导入响应.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/import.html
type ImportJSONResponse struct {
RequestID string `json:"-"`
TaskID string `json:"task_id"`
}
// PublishResponse 发布响应.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/publish.html
type PublishResponse struct {
RequestID string `json:"-"`
TaskID string `json:"task_id"`
}
// EffectiveProgressResponse 发布进度查询响应.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/progress.html
type EffectiveProgressResponse struct {
RequestID string `json:"-"`
EndTime string `json:"end_time"`
Progress int `json:"progress"`
Status int `json:"status"`
}
// FetchAsyncResponse 异步任务查询响应.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/fetch.html
type FetchAsyncResponse struct {
RequestID string `json:"-"`
State int `json:"state"`
Msg string `json:"msg"`
Progress int `json:"progress"`
Start int64 `json:"start"`
End int64 `json:"end"`
URL string `json:"url"`
TotalCount int `json:"total_count"`
SuccessCount int `json:"success_count"`
FailCount int `json:"fail_count"`
ReplaceCount int `json:"replace_count"`
SuccessUploadCount int `json:"success_upload_count"`
SuccessSkillInfo []SkillInfo `json:"success_skill_info"`
FailSkillInfo []SkillInfo `json:"fail_skill_info"`
}
// SkillInfo 技能信息.
type SkillInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
Intents []IntentInfo `json:"intents"`
}
// IntentInfo 意图信息.
type IntentInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
// QueryResponse 调用智能对话响应.
// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/query.html
type QueryResponse struct {
RequestID string `json:"-"`
Answer string `json:"answer"`
RawAnswer json.RawMessage `json:"-"`
AnswerType string `json:"answer_type"`
SkillName string `json:"skill_name"`
IntentName string `json:"intent_name"`
MsgID string `json:"msg_id"`
Options []Option `json:"options"`
Status string `json:"status"`
Slots []SlotDetail `json:"slots"`
}
// Option 推荐问题.
type Option struct {
AnsNodeName string `json:"ans_node_name"`
Title string `json:"title"`
Answer string `json:"answer"`
Confidence float64 `json:"confidence"`
}
// SlotDetail 槽位数据.
type SlotDetail struct {
Name string `json:"name"`
Value string `json:"value"`
Norm string `json:"norm"`
}
func decodeResponse(response []byte, data interface{}, apiName string) (string, error) {
var res apiResponse
if err := json.Unmarshal(response, &res); err != nil {
return "", fmt.Errorf("json Unmarshal Error, err=%v", err)
}
if res.Code != 0 {
return "", &APIError{
APIName: apiName,
Code: res.Code,
Msg: res.Msg,
RequestID: res.RequestID,
Data: res.Data,
}
}
if data != nil && len(res.Data) > 0 && string(res.Data) != "null" {
if err := json.Unmarshal(res.Data, data); err != nil {
return "", fmt.Errorf("json Unmarshal Data Error, err=%v", err)
}
}
return res.RequestID, nil
}
+89
View File
@@ -0,0 +1,89 @@
package dialog
import (
stdcontext "context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"github.com/silenceper/wechat/v2/aispeech/config"
"github.com/silenceper/wechat/v2/cache"
)
const accessTokenCacheKeyPrefix = "gowechat_aispeech_"
var errEmptyAccessToken = errors.New("aispeech access_token is empty")
// AccessToken 微信智能对话 access token.
type AccessToken struct {
cfg *config.Config
accessTokenLock *sync.Mutex
}
// NewAccessToken new AccessToken.
func NewAccessToken(cfg *config.Config) *AccessToken {
if cfg.Cache == nil {
panic("cache is needed")
}
return &AccessToken{
cfg: cfg,
accessTokenLock: new(sync.Mutex),
}
}
// GetAccessToken 获取 access token.
func (ak *AccessToken) GetAccessToken() (string, error) {
return ak.GetAccessTokenContext(stdcontext.Background())
}
// GetAccessTokenContext 获取 access token.
func (ak *AccessToken) GetAccessTokenContext(ctx stdcontext.Context) (string, error) {
cacheKey := fmt.Sprintf("%s_access_token_%s_%s", accessTokenCacheKeyPrefix, ak.cfg.AppID, ak.cfg.Account)
if val := cache.GetContext(ctx, ak.cfg.Cache, cacheKey); val != nil {
if accessToken, ok := val.(string); ok && accessToken != "" {
return accessToken, nil
}
}
ak.accessTokenLock.Lock()
defer ak.accessTokenLock.Unlock()
if val := cache.GetContext(ctx, ak.cfg.Cache, cacheKey); val != nil {
if accessToken, ok := val.(string); ok && accessToken != "" {
return accessToken, nil
}
}
accessToken, err := ak.getAccessTokenFromServer(ctx)
if err != nil {
return "", err
}
if err = cache.SetContext(ctx, ak.cfg.Cache, cacheKey, accessToken, 110*time.Minute); err != nil {
return "", err
}
return accessToken, nil
}
func (ak *AccessToken) getAccessTokenFromServer(ctx stdcontext.Context) (string, error) {
req := &AccessTokenRequest{Account: ak.cfg.Account}
body, err := json.Marshal(req)
if err != nil {
return "", err
}
response, err := post(ctx, ak.cfg, tokenPath, body, "application/json", "", ak.cfg.AppID)
if err != nil {
return "", err
}
var res accessTokenData
if _, err = decodeResponse(response, &res, "AISpeechGetAccessToken"); err != nil {
return "", err
}
if res.AccessToken == "" {
return "", errEmptyAccessToken
}
return res.AccessToken, nil
}
+100
View File
@@ -0,0 +1,100 @@
package encryptor
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"errors"
"fmt"
)
var (
// ErrInvalidBlockSize block size 不合法.
ErrInvalidBlockSize = errors.New("invalid block size")
// ErrInvalidPKCS7Data PKCS7 数据不合法.
ErrInvalidPKCS7Data = errors.New("invalid PKCS7 data")
// ErrInvalidPKCS7Padding padding 不合法.
ErrInvalidPKCS7Padding = errors.New("invalid PKCS7 padding")
)
// Encrypt encrypts plaintext with AES-CBC and returns base64 ciphertext.
func Encrypt(aesKey string, plaintext []byte) (string, error) {
key, err := decodeAESKey(aesKey)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
plaintext = pkcs7Pad(plaintext, block.BlockSize())
ciphertext := make([]byte, len(plaintext))
mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize])
mode.CryptBlocks(ciphertext, plaintext)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts base64 AES-CBC ciphertext.
func Decrypt(aesKey, ciphertext string) ([]byte, error) {
key, err := decodeAESKey(aesKey)
if err != nil {
return nil, err
}
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return nil, err
}
if len(data) == 0 || len(data)%aes.BlockSize != 0 {
return nil, ErrInvalidPKCS7Data
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
plaintext := make([]byte, len(data))
mode := cipher.NewCBCDecrypter(block, key[:aes.BlockSize])
mode.CryptBlocks(plaintext, data)
return pkcs7Unpad(plaintext, block.BlockSize())
}
func decodeAESKey(encodingAESKey string) ([]byte, error) {
key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
return nil, err
}
if len(key) != 32 {
return nil, fmt.Errorf("encodingAESKey invalid")
}
return key, nil
}
func pkcs7Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padText...)
}
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
if blockSize <= 0 {
return nil, ErrInvalidBlockSize
}
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, ErrInvalidPKCS7Data
}
padding := int(data[len(data)-1])
if padding == 0 || padding > blockSize || padding > len(data) {
return nil, ErrInvalidPKCS7Padding
}
for i := len(data) - padding; i < len(data); i++ {
if int(data[i]) != padding {
return nil, ErrInvalidPKCS7Padding
}
}
return data[:len(data)-padding], nil
}
+33
View File
@@ -0,0 +1,33 @@
package encryptor
import "testing"
func TestSign(t *testing.T) {
sign := Sign("YV78Pyj1VvqdNGpMJ1pHic0bIBOWMv", 1711001766, "abc", nil)
if sign != "fff8dae1356e7867ea98743439f0e9f8" {
t.Fatalf("bad sign: %s", sign)
}
}
func TestEncryptDecrypt(t *testing.T) {
aesKey := "q1Os1ZMe0nG28KUEx9lg3HjK7V5QyXvi212fzsgDqgz"
plainText := []byte(`{"query":"hello"}`)
cipherText, err := Encrypt(aesKey, plainText)
if err != nil {
t.Fatalf("Encrypt error: %v", err)
}
got, err := Decrypt(aesKey, cipherText)
if err != nil {
t.Fatalf("Decrypt error: %v", err)
}
if string(got) != string(plainText) {
t.Fatalf("bad plaintext: %s", got)
}
}
func TestDecryptInvalidKey(t *testing.T) {
if _, err := Encrypt("bad-key", []byte("hello")); err == nil {
t.Fatal("Encrypt should return error")
}
}
+20
View File
@@ -0,0 +1,20 @@
package encryptor
import (
"crypto/md5"
"encoding/hex"
"strconv"
)
// Sign calculates the aispeech request signature.
func Sign(token string, timestamp int64, nonce string, body []byte) string {
bodyMD5 := md5.Sum(body)
bodySign := hex.EncodeToString(bodyMD5[:])
h := md5.New()
h.Write([]byte(token))
h.Write([]byte(strconv.FormatInt(timestamp, 10)))
h.Write([]byte(nonce))
h.Write([]byte(bodySign))
return hex.EncodeToString(h.Sum(nil))
}
+43 -27
View File
@@ -5,53 +5,64 @@ import (
"time"
)
// Memory struct contains *memcache.Client
// Memory is an concurrent-safe in-process cache backed by a plain map.
type Memory struct {
sync.Mutex
mu sync.RWMutex
data map[string]*data
}
// data holds a single cached value together with its expiry time.
type data struct {
Data interface{}
Expired time.Time
}
// NewMemory create new memcache
// NewMemory returns a ready-to-use in-memory cache.
func NewMemory() *Memory {
return &Memory{
data: map[string]*data{},
}
}
// Get return cached value
// Get returns the cached value for key.
// Returns nil if the key does not exist or has expired.
func (mem *Memory) Get(key string) interface{} {
if ret, ok := mem.data[key]; ok {
if ret.Expired.Before(time.Now()) {
mem.deleteKey(key)
return nil
}
return ret.Data
mem.mu.RLock()
ret, ok := mem.data[key]
expired := ok && ret.Expired.Before(time.Now())
mem.mu.RUnlock()
if !ok {
return nil
}
return nil
if expired {
mem.deleteExpiredKey(key)
return nil
}
return ret.Data
}
// IsExist check value exists in memcache.
func (mem *Memory) IsExist(key string) bool {
if ret, ok := mem.data[key]; ok {
if ret.Expired.Before(time.Now()) {
mem.deleteKey(key)
return false
}
return true
mem.mu.RLock()
ret, ok := mem.data[key]
expired := ok && ret.Expired.Before(time.Now())
mem.mu.RUnlock()
if !ok {
return false
}
return false
if expired {
mem.deleteExpiredKey(key)
return false
}
return true
}
// Set cached value with key and expire time.
func (mem *Memory) Set(key string, val interface{}, timeout time.Duration) (err error) {
mem.Lock()
defer mem.Unlock()
mem.mu.Lock()
defer mem.mu.Unlock()
mem.data[key] = &data{
Data: val,
@@ -62,13 +73,18 @@ func (mem *Memory) Set(key string, val interface{}, timeout time.Duration) (err
// Delete delete value in memcache.
func (mem *Memory) Delete(key string) error {
mem.deleteKey(key)
mem.mu.Lock()
defer mem.mu.Unlock()
delete(mem.data, key)
return nil
}
// deleteKey
func (mem *Memory) deleteKey(key string) {
mem.Lock()
defer mem.Unlock()
delete(mem.data, key)
// deleteExpiredKey deletes a key only if it has expired.
// Caller must NOT hold any lock.
func (mem *Memory) deleteExpiredKey(key string) {
mem.mu.Lock()
defer mem.mu.Unlock()
if d, ok := mem.data[key]; ok && d.Expired.Before(time.Now()) {
delete(mem.data, key)
}
}
+116
View File
@@ -0,0 +1,116 @@
// 运行测试:go test -race -v ./cache/ -run "TestMemory" -count=1
package cache
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestMemoryGet(t *testing.T) {
mem := NewMemory()
err := mem.Set("username", "silenceper", 10*time.Second)
assert.NoError(t, err)
val := mem.Get("username")
assert.Equal(t, "silenceper", val)
val = mem.Get("unknown-key")
assert.Nil(t, val)
}
func TestMemoryIsExist(t *testing.T) {
mem := NewMemory()
err := mem.Set("username", "silenceper", 10*time.Second)
assert.NoError(t, err)
assert.True(t, mem.IsExist("username"))
assert.False(t, mem.IsExist("unknown-key"))
}
func TestMemoryDelete(t *testing.T) {
mem := NewMemory()
err := mem.Set("username", "silenceper", 10*time.Second)
assert.NoError(t, err)
err = mem.Delete("username")
assert.NoError(t, err)
// delete 不存在的 key 不应报错
err = mem.Delete("unknown-key")
assert.NoError(t, err)
}
func TestMemoryExpire(t *testing.T) {
mem := NewMemory()
err := mem.Set("username", "silenceper", 10*time.Millisecond)
assert.NoError(t, err)
assert.True(t, mem.IsExist("username"))
time.Sleep(20 * time.Millisecond)
assert.False(t, mem.IsExist("username"))
assert.Nil(t, mem.Get("username"))
}
// TestMemoryConcurrentOps 验证 Get/IsExist/Set/Delete 并发执行不产生数据竞争
func TestMemoryConcurrentOps(t *testing.T) {
mem := NewMemory()
_ = mem.Set("key", "value", time.Minute)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(4)
// 并发读
go func() {
defer wg.Done()
_ = mem.Get("key")
}()
// 并发 IsExist
go func() {
defer wg.Done()
_ = mem.IsExist("key")
}()
// 并发写
go func(i int) {
defer wg.Done()
_ = mem.Set("key", i, time.Minute)
}(i)
// 并发删除
go func() {
defer wg.Done()
_ = mem.Delete("key")
}()
}
wg.Wait()
}
// TestMemoryConcurrentExpireAndRead 验证过期惰性删除在并发场景下不会死锁
func TestMemoryConcurrentExpireAndRead(t *testing.T) {
mem := NewMemory()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(2)
go func(i int) {
defer wg.Done()
key := "expire-key"
_ = mem.Set(key, i, 5*time.Millisecond)
}(i)
go func() {
defer wg.Done()
time.Sleep(3 * time.Millisecond)
// 此时 key 可能已过期,触发 deleteKey,验证不死锁
_ = mem.Get("expire-key")
_ = mem.IsExist("expire-key")
}()
}
wg.Wait()
}
+6 -2
View File
@@ -56,7 +56,9 @@ func (js *DefaultJsTicket) GetTicketContext(ctx context2.Context, accessToken st
// 先从cache中取
jsAPITicketCacheKey := fmt.Sprintf("%s_jsapi_ticket_%s", js.cacheKeyPrefix, js.appID)
if val := js.cache.Get(jsAPITicketCacheKey); val != nil {
return val.(string), nil
if ticketStr = val.(string); ticketStr != "" {
return
}
}
js.jsAPITicketLock.Lock()
@@ -64,7 +66,9 @@ func (js *DefaultJsTicket) GetTicketContext(ctx context2.Context, accessToken st
// 双检,防止重复从微信服务器获取
if val := js.cache.Get(jsAPITicketCacheKey); val != nil {
return val.(string), nil
if ticketStr = val.(string); ticketStr != "" {
return
}
}
var ticket ResTicket
+10 -1
View File
@@ -1,3 +1,12 @@
# 智能对话
TODO
## 接入智能对话
- 获取 Accesstoken:`POST /v2/token`
- 简单问答导入:`POST /v2/bot/import/json`
- 异步任务查询:`POST /v2/async/fetch`
- 发布机器人:`POST /v2/bot/publish`
- 查询机器人发布进度:`POST /v2/bot/effective_progress`
- 调用智能对话:`POST /v2/bot/query`
官方文档:https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html
+9
View File
@@ -48,3 +48,12 @@ TODO
| :----------------: | -------- | :------------------- | ---------- | ------------------------------------------ |
| 获取用户的安全等级 | POST | /wxa/getuserriskrank | YES | (riskControl *RiskControl) GetUserRiskRank |
## 学生身份
[官方文档](https://developers.weixin.qq.com/miniprogram/dev/server/API/student/api_quickcheckstudentidentity.html)
| 名称 | 请求方式 | URL | 是否已实现 | 使用方法 |
| :--------------: | -------- | :------------------------------ | ---------- | ----------------------------------------------- |
| 快速获取学生身份 | POST | /intp/quickcheckstudentidentity | YES | (student *Student) QuickCheckStudentIdentity |
+127
View File
@@ -0,0 +1,127 @@
package generic
import (
context2 "context"
"net/url"
"strings"
"github.com/silenceper/wechat/v2/miniprogram/context"
"github.com/silenceper/wechat/v2/util"
)
// defaultBaseURL 微信 API 默认基础地址
const defaultBaseURL = "https://api.weixin.qq.com"
// Generic 通用 API 调用,用于访问 SDK 尚未封装的小程序接口。
// 支持自定义 path、query 参数、request body 以及 response 结构体。
type Generic struct {
*context.Context
}
// NewGeneric init
func NewGeneric(ctx *context.Context) *Generic {
return &Generic{ctx}
}
// BuildURL 构建完整请求 URL。
// path 支持相对路径(如 "/intp/xxx")或完整 URL(如 "https://api.weixin.qq.com/intp/xxx")。
// query 为额外的 URL 查询参数,可为 nil;access_token 会自动追加(若 query 中已包含则不重复追加)。
func (g *Generic) BuildURL(path string, query url.Values) (string, error) {
return g.BuildURLContext(context2.Background(), path, query)
}
// BuildURLContext 构建完整请求 URL(带 context)
func (g *Generic) BuildURLContext(ctx context2.Context, path string, query url.Values) (string, error) {
var baseURL string
switch {
case strings.HasPrefix(path, "http://"), strings.HasPrefix(path, "https://"):
baseURL = path
case strings.HasPrefix(path, "/"):
baseURL = defaultBaseURL + path
default:
baseURL = defaultBaseURL + "/" + path
}
// 克隆 query,避免修改调用方原始值
q := make(url.Values, len(query)+1)
for k, v := range query {
q[k] = v
}
// 自动追加 access_token
if q.Get("access_token") == "" {
accessToken, err := g.GetAccessTokenContext(ctx)
if err != nil {
return "", err
}
q.Set("access_token", accessToken)
}
sep := "?"
if strings.Contains(baseURL, "?") {
sep = "&"
}
return baseURL + sep + q.Encode(), nil
}
// Get 发送 GET 请求,返回原始响应字节。
// 使用 util.DecodeWithError 可将响应解码到自定义结构体。
func (g *Generic) Get(path string, query url.Values) (response []byte, err error) {
return g.GetContext(context2.Background(), path, query)
}
// GetContext 发送 GET 请求(带 context),返回原始响应字节
func (g *Generic) GetContext(ctx context2.Context, path string, query url.Values) (response []byte, err error) {
var uri string
if uri, err = g.BuildURLContext(ctx, path, query); err != nil {
return
}
return util.HTTPGetContext(ctx, uri)
}
// PostJSON 发送 POST 请求(JSON body),返回原始响应字节。
// body 可为 nil(无请求体);使用 util.DecodeWithError 可将响应解码到自定义结构体。
func (g *Generic) PostJSON(path string, query url.Values, body interface{}) (response []byte, err error) {
return g.PostJSONContext(context2.Background(), path, query, body)
}
// PostJSONContext 发送 POST 请求(JSON body,带 context),返回原始响应字节
func (g *Generic) PostJSONContext(ctx context2.Context, path string, query url.Values, body interface{}) (response []byte, err error) {
var uri string
if uri, err = g.BuildURLContext(ctx, path, query); err != nil {
return
}
return util.PostJSONContext(ctx, uri, body)
}
// GetDecode 发送 GET 请求并将响应解码到 resp。
// resp 应为指向结构体的指针,结构体中可嵌入 util.CommonError 以自动校验错误码。
// apiName 用于错误信息标识。
func (g *Generic) GetDecode(path string, query url.Values, resp interface{}, apiName string) (err error) {
return g.GetDecodeContext(context2.Background(), path, query, resp, apiName)
}
// GetDecodeContext 发送 GET 请求并将响应解码到 resp(带 context)
func (g *Generic) GetDecodeContext(ctx context2.Context, path string, query url.Values, resp interface{}, apiName string) (err error) {
var response []byte
if response, err = g.GetContext(ctx, path, query); err != nil {
return
}
return util.DecodeWithError(response, resp, apiName)
}
// PostJSONDecode 发送 POST 请求(JSON body)并将响应解码到 resp。
// resp 应为指向结构体的指针,结构体中可嵌入 util.CommonError 以自动校验错误码。
// apiName 用于错误信息标识。
func (g *Generic) PostJSONDecode(path string, query url.Values, body, resp interface{}, apiName string) (err error) {
return g.PostJSONDecodeContext(context2.Background(), path, query, body, resp, apiName)
}
// PostJSONDecodeContext 发送 POST 请求(JSON body)并将响应解码到 resp(带 context)
func (g *Generic) PostJSONDecodeContext(ctx context2.Context, path string, query url.Values, body, resp interface{}, apiName string) (err error) {
var response []byte
if response, err = g.PostJSONContext(ctx, path, query, body); err != nil {
return
}
return util.DecodeWithError(response, resp, apiName)
}
+83
View File
@@ -41,6 +41,12 @@ const (
EventTypeXpayGoodsDeliverNotify EventType = "xpay_goods_deliver_notify"
// EventTypeXpayCoinPayNotify 代币支付推送事件
EventTypeXpayCoinPayNotify EventType = "xpay_coin_pay_notify"
// EventTypeXpayRefundNotify 退款推送事件
EventTypeXpayRefundNotify EventType = "xpay_refund_notify"
// EventTypeXpaySubscribeIosRefundQueryNotify iOS Apple 支付退款问询事件
EventTypeXpaySubscribeIosRefundQueryNotify EventType = "xpay_subscribe_ios_refund_query_notify"
// EventTypeXpayComplaintNotify 用户投诉推送事件
EventTypeXpayComplaintNotify EventType = "xpay_complaint_notify"
// EventSubscribePopup 用户操作订阅通知弹窗事件推送,用户在图文等场景内订阅通知的操作
EventSubscribePopup EventType = "subscribe_msg_popup_event"
// EventSubscribeMsgChange 用户管理订阅通知,用户在服务通知管理页面做通知管理时的操作
@@ -203,6 +209,14 @@ func (receiver *PushReceiver) getEvent(dataType string, eventType EventType, dec
var pushData PushDataXpayCoinPayNotify
err := receiver.unmarshal(dataType, decryptMsg, &pushData)
return &pushData, err
case EventTypeXpayRefundNotify:
// 退款推送事件
return receiver.unmarshalPushData(dataType, decryptMsg, &PushDataXpayRefundNotify{})
case EventTypeXpaySubscribeIosRefundQueryNotify: // iOS Apple 支付退款问询事件
return receiver.unmarshalPushData(dataType, decryptMsg, &PushDataXpaySubscribeIosRefundQueryNotify{})
case EventTypeXpayComplaintNotify:
// 用户投诉推送事件
return receiver.unmarshalPushData(dataType, decryptMsg, &PushDataXpayComplaintNotify{})
case EventSubscribePopup:
// 用户操作订阅通知弹窗事件推送
return receiver.unmarshalSubscribePopup(dataType, decryptMsg)
@@ -225,6 +239,12 @@ func (receiver *PushReceiver) unmarshal(dataType string, decryptMsg []byte, push
return json.Unmarshal(decryptMsg, pushData)
}
// unmarshalPushData unmarshal push data helper
func (receiver *PushReceiver) unmarshalPushData(dataType string, decryptMsg []byte, pushData interface{}) (PushData, error) {
err := receiver.unmarshal(dataType, decryptMsg, pushData)
return pushData, err
}
// unmarshalSubscribePopup
func (receiver *PushReceiver) unmarshalSubscribePopup(dataType string, decryptMsg []byte) (PushData, error) {
var pushData PushDataSubscribePopup
@@ -432,6 +452,7 @@ type PushDataXpayGoodsDeliverNotify struct {
Env int `json:"Env" xml:"Env"` // ,环境配置 0:现网环境(也叫正式环境)1:沙箱环境
WeChatPayInfo WeChatPayInfo `json:"WeChatPayInfo" xml:"WeChatPayInfo"` // 微信支付信息 非微信支付渠道可能没有
GoodsInfo GoodsInfo `json:"GoodsInfo" xml:"GoodsInfo"` // 道具参数信息
TeamInfo XpayTeamInfo `json:"TeamInfo" xml:"TeamInfo"` // 拼团信息
}
// WeChatPayInfo 微信支付信息
@@ -577,3 +598,65 @@ func (s *PushDataSubscribeMsgSent) GetSubscribeMsgSentEvents() []SubscribeMsgSen
return s.SubscribeMsgSentEvent.List
}
// XpayTeamInfo team purchase info
type XpayTeamInfo struct {
ActivityID string `json:"ActivityId" xml:"ActivityId"` // 活动 id
TeamID string `json:"TeamId" xml:"TeamId"` // 团 id
TeamType int `json:"TeamType" xml:"TeamType"` // 团类型 1-支付全部,拼成退款
TeamAction int `json:"TeamAction" xml:"TeamAction"` // 0-创团 1-参团
}
// PushDataXpayRefundNotify 退款推送
type PushDataXpayRefundNotify struct {
CommonPushData
OpenID string `json:"OpenId" xml:"OpenId"` // 用户 openid
WxRefundID string `json:"WxRefundId" xml:"WxRefundId"` // 微信退款单号
MchRefundID string `json:"MchRefundId" xml:"MchRefundId"` // 商户退款单号
WxOrderID string `json:"WxOrderId" xml:"WxOrderId"` // 退款单对应支付单的微信单号
MchOrderID string `json:"MchOrderId" xml:"MchOrderId"` // 退款单对应支付单的商户单号
RefundFee int `json:"RefundFee" xml:"RefundFee"` // 退款金额,单位分
RetCode int `json:"RetCode" xml:"RetCode"` // 退款结果,0 为成功
RetMsg string `json:"RetMsg" xml:"RetMsg"` // 退款结果详情
RefundStartTimestamp int64 `json:"RefundStartTimestamp" xml:"RefundStartTimestamp"` // 开始退款时间,秒级时间戳
RefundSuccTimestamp int64 `json:"RefundSuccTimestamp" xml:"RefundSuccTimestamp"` // 结束退款时间,秒级时间戳
WxpayRefundTransactionID string `json:"WxpayRefundTransactionId" xml:"WxpayRefundTransactionId"` // 退款单的微信支付单号
RetryTimes int `json:"RetryTimes" xml:"RetryTimes"` // 重试次数,从 0 开始
TeamInfo XpayTeamInfo `json:"TeamInfo" xml:"TeamInfo"` // 拼团信息
}
// PushDataXpaySubscribeIosRefundQueryNotify iOS Apple 支付退款问询事件
// 文档:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment/ios.html
type PushDataXpaySubscribeIosRefundQueryNotify struct {
CommonPushData
RefundTime string `json:"refund_time" xml:"refund_time"` // 问询时间,Unix 时间戳
OrderTime string `json:"order_time" xml:"order_time"` // 该笔退款的订单时间,Unix 时间戳
ChannelBill string `json:"channel_bill" xml:"channel_bill"` // Apple 支付票据号
BundleID string `json:"bundleid" xml:"bundleid"` // 应用的 Apple bundleid
ProductID string `json:"product_id" xml:"product_id"` // 道具 id
PCount string `json:"p_count" xml:"p_count"` // 道具/代币数量
RefundRequestReason string `json:"refund_request_reason" xml:"refund_request_reason"` // 用户请求退款的原因
ProvideStatus string `json:"provide_status" xml:"provide_status"` // 发货状态,0:未发货 1:已发货 2:发货中
PayOrderID string `json:"pay_order_id" xml:"pay_order_id"` // 退款对应支付订单号
}
// IosRefundQueryResponse iOS Apple 支付退款问询应答
type IosRefundQueryResponse struct {
ResultCode int32 `json:"result_code" xml:"result_code"` // 结果码,0-放过,建议退款;1-拦截,拒绝退款
ResultInfo string `json:"result_info" xml:"result_info"` // 结果描述
Evidence string `json:"evidence" xml:"evidence"` // 决策凭据,必填,用于退款审计
}
// PushDataXpayComplaintNotify 用户投诉推送
type PushDataXpayComplaintNotify struct {
CommonPushData
OpenID string `json:"OpenId" xml:"OpenId"` // 用户 openid
WxOrderID string `json:"WxOrderId" xml:"WxOrderId"` // 微信单号
MchOrderID string `json:"MchOrderId" xml:"MchOrderId"` // 商户单号
TransactionID string `json:"TransactionId" xml:"TransactionId"` // 微信支付交易单号
ComplaintID string `json:"ComplaintId" xml:"ComplaintId"` // 投诉单号
ComplaintDetail string `json:"ComplaintDetail" xml:"ComplaintDetail"` // 投诉详情
ComplaintTime int64 `json:"ComplaintTime" xml:"ComplaintTime"` // 投诉时间,秒级时间戳
RetryTimes int `json:"RetryTimes" xml:"RetryTimes"` // 重试次数,从 0 开始
RequestID string `json:"RequestId" xml:"RequestId"` // 请求编号
}
+12
View File
@@ -11,6 +11,7 @@ import (
"github.com/silenceper/wechat/v2/miniprogram/context"
"github.com/silenceper/wechat/v2/miniprogram/encryptor"
"github.com/silenceper/wechat/v2/miniprogram/express"
"github.com/silenceper/wechat/v2/miniprogram/generic"
"github.com/silenceper/wechat/v2/miniprogram/message"
"github.com/silenceper/wechat/v2/miniprogram/minidrama"
"github.com/silenceper/wechat/v2/miniprogram/ocr"
@@ -22,6 +23,7 @@ import (
"github.com/silenceper/wechat/v2/miniprogram/riskcontrol"
"github.com/silenceper/wechat/v2/miniprogram/security"
"github.com/silenceper/wechat/v2/miniprogram/shortlink"
"github.com/silenceper/wechat/v2/miniprogram/student"
"github.com/silenceper/wechat/v2/miniprogram/subscribe"
"github.com/silenceper/wechat/v2/miniprogram/tcb"
"github.com/silenceper/wechat/v2/miniprogram/urllink"
@@ -202,3 +204,13 @@ func (miniProgram *MiniProgram) GetOCR() *ocr.OCR {
func (miniProgram *MiniProgram) GetIntracity() *express.Express {
return express.NewExpress(miniProgram.ctx)
}
// GetStudent 学生身份核验接口
func (miniProgram *MiniProgram) GetStudent() *student.Student {
return student.NewStudent(miniProgram.ctx)
}
// GetGeneric 通用 API 调用,用于访问 SDK 尚未封装的接口
func (miniProgram *MiniProgram) GetGeneric() *generic.Generic {
return generic.NewGeneric(miniProgram.ctx)
}
+1 -1
View File
@@ -55,7 +55,7 @@ type QRCoder struct {
// envVersion 要打开的小程序版本。正式版为 "release",体验版为 "trial",开发版为 "develop"
EnvVersion string `json:"env_version,omitempty"`
// ShowSplashAd 控制通过该小程序码进入小程序是否展示封面广告1、默认为true,展示封面广告2、传入为false时,不展示封面广告
ShowSplashAd bool `json:"show_splash_ad,omitempty"`
ShowSplashAd *bool `json:"show_splash_ad,omitempty"`
}
// fetchCode 请求并返回二维码二进制数据
+73
View File
@@ -0,0 +1,73 @@
package student
import (
context2 "context"
"fmt"
"github.com/silenceper/wechat/v2/miniprogram/context"
"github.com/silenceper/wechat/v2/util"
)
const (
// quickCheckStudentIdentityURL 快速获取学生身份
quickCheckStudentIdentityURL = "https://api.weixin.qq.com/intp/quickcheckstudentidentity?access_token=%s"
)
// Student 学生身份核验
type Student struct {
*context.Context
}
// NewStudent init
func NewStudent(ctx *context.Context) *Student {
return &Student{ctx}
}
// BindStatus 学生身份绑定状态
type BindStatus int
const (
// BindStatusUnbound 未绑定
BindStatusUnbound BindStatus = 1
// BindStatusAuditing 审核中
BindStatusAuditing BindStatus = 2
// BindStatusBound 已绑定
BindStatusBound BindStatus = 3
)
// QuickCheckStudentIdentityRequest 快速获取学生身份请求参数
type QuickCheckStudentIdentityRequest struct {
OpenID string `json:"openid"` // 用户在业务方下的openid,需为已开通接口权限小程序对应的用户openid
WxStudentCheckCode string `json:"wx_studentcheck_code"` // 用户授权查询code,由授权插件返回,code有效期两小时
}
// QuickCheckStudentIdentityResponse 快速获取学生身份返回参数
type QuickCheckStudentIdentityResponse struct {
util.CommonError
BindStatus BindStatus `json:"bind_status"` // 绑定状态:1-未绑定,2-审核中,3-已绑定
IsStudent bool `json:"is_student"` // 用户学生身份绑定状态说明,true-是学生,false-不是学生
}
// QuickCheckStudentIdentity 快速获取学生身份
// see https://developers.weixin.qq.com/miniprogram/dev/server/API/student/api_quickcheckstudentidentity.html
func (student *Student) QuickCheckStudentIdentity(in *QuickCheckStudentIdentityRequest) (out QuickCheckStudentIdentityResponse, err error) {
return student.QuickCheckStudentIdentityContext(context2.Background(), in)
}
// QuickCheckStudentIdentityContext 快速获取学生身份
func (student *Student) QuickCheckStudentIdentityContext(ctx context2.Context, in *QuickCheckStudentIdentityRequest) (out QuickCheckStudentIdentityResponse, err error) {
var accessToken string
if accessToken, err = student.GetAccessTokenContext(ctx); err != nil {
return
}
uri := fmt.Sprintf(quickCheckStudentIdentityURL, accessToken)
var response []byte
if response, err = util.PostJSONContext(ctx, uri, in); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "student.QuickCheckStudentIdentity")
return
}
+75
View File
@@ -51,6 +51,30 @@ const (
SessionKeyExpiredError ErrCode = 268490009
// BillGeneratingError 错误码 268490011、账单数据生成中,请稍后调用本接口获取
BillGeneratingError ErrCode = 268490011
// BatchTaskRunningError 错误码 268490012、批量任务运行中,请等待完成后才能再次运行
BatchTaskRunningError ErrCode = 268490012
// RefundVerifiedOrderError 错误码 268490013、禁止对核销状态的单进行退款
RefundVerifiedOrderError ErrCode = 268490013
// RefundInProgressError 错误码 268490014、退款操作进行中,稍后可以使用相同参数重试
RefundInProgressError ErrCode = 268490014
// FrequencyLimitError 错误码 268490015、频率限制
FrequencyLimitError ErrCode = 268490015
// RefundLeftFeeMismatchError 错误码 268490016、退款的 left_fee 字段与实际不符
RefundLeftFeeMismatchError ErrCode = 268490016
// AdFundIndustryMismatchError 错误码 268490018、广告金充值帐户行业 id 不匹配
AdFundIndustryMismatchError ErrCode = 268490018
// AdFundAccountBoundError 错误码 268490019、广告金充值帐户 id 已绑定其他 appid
AdFundAccountBoundError ErrCode = 268490019
// AdFundAccountNameError 错误码 268490020、广告金充值帐户主体名称错误
AdFundAccountNameError ErrCode = 268490020
// AccountNotVerifiedError 错误码 268490021、账户未完成进件
AccountNotVerifiedError ErrCode = 268490021
// AdFundAccountInvalidError 错误码 268490022、广告金充值账户无效
AdFundAccountInvalidError ErrCode = 268490022
// AdFundInsufficientError 错误码 268490023、广告金余额不足
AdFundInsufficientError ErrCode = 268490023
// AdFundAmountMustPositiveError 错误码 268490024、广告金充值金额必须大于 0
AdFundAmountMustPositiveError ErrCode = 268490024
)
const (
@@ -117,6 +141,57 @@ const (
// queryPublishGoods 查询批量发布道具任务状态
queryPublishGoods = "/xpay/query_publish_goods"
// startDownloadOrder 发起下载小程序订单明细任务
startDownloadOrder = "/xpay/start_download_order"
// queryDownloadOrder 查询下载订单任务结果
queryDownloadOrder = "/xpay/query_download_order"
// queryBizBalance 查询商家账户可提现余额
queryBizBalance = "/xpay/query_biz_balance"
// queryTransferAccount 查询广告金充值账户
queryTransferAccount = "/xpay/query_transfer_account"
// queryAdverFunds 查询广告金发放记录
queryAdverFunds = "/xpay/query_adver_funds"
// createFundsBill 充值广告金
createFundsBill = "/xpay/create_funds_bill"
// bindTransferAccount 绑定广告金充值账户
bindTransferAccount = "/xpay/bind_transfer_accout"
// queryFundsBill 查询广告金充值记录
queryFundsBill = "/xpay/query_funds_bill"
// queryRecoverBill 查询广告金回收记录
queryRecoverBill = "/xpay/query_recover_bill"
// downloadAdverFundsOrder 下载广告金对应的商户订单信息
downloadAdverFundsOrder = "/xpay/download_adverfunds_order"
// getComplaintList 获取投诉列表
getComplaintList = "/xpay/get_complaint_list"
// getComplaintDetail 获取投诉详情
getComplaintDetail = "/xpay/get_complaint_detail"
// getNegotiationHistory 获取协商历史
getNegotiationHistory = "/xpay/get_negotiation_history"
// responseComplaint 回复用户
responseComplaint = "/xpay/response_complaint"
// completeComplaint 完成投诉处理
completeComplaint = "/xpay/complete_complaint"
// uploadVPFile 上传媒体文件
uploadVPFile = "/xpay/upload_vp_file"
// getUploadFileSign 获取微信支付反馈投诉图片的签名头部
getUploadFileSign = "/xpay/get_upload_file_sign"
)
const (
+443 -1
View File
@@ -25,7 +25,7 @@ import (
)
// VirtualPayment mini program virtual payment
// https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/industry/virtual-payment.html#_2-3-%E6%9C%8D%E5%8A%A1%E5%99%A8API
// https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment.html
type VirtualPayment struct {
ctx *context.Context
sessionKey string
@@ -368,12 +368,14 @@ type AsyncXPayGoodsDeliverNotifyRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
WechatPayInfo *WeChatPayInfo `json:"WechatPayInfo"` // 微信支付订单信息
GoodsInfo *GoodsInfo `json:"GoodsInfo"` // 道具信息
TeamInfo *TeamInfo `json:"TeamInfo"` // 拼团信息
}
// WeChatPayInfo 微信支付信息 非微信支付渠道可能没有
type WeChatPayInfo struct {
MchOrderNo string `json:"MchOrderNo"` // 商户订单号
TransactionID string `json:"TransactionId"` // 微信支付订单号
PaidTime int64 `json:"PaidTime"` // 用户支付时间,Linux 秒级时间戳
}
// GoodsInfo 道具参数信息
@@ -385,6 +387,14 @@ type GoodsInfo struct {
Attach string `json:"Attach"` // 透传信息
}
// TeamInfo 拼团信息
type TeamInfo struct {
ActivityID string `json:"ActivityId"` // 活动 id
TeamID string `json:"TeamId"` // 团 id
TeamType int `json:"TeamType"` // 团类型 1-支付全部,拼成退款
TeamAction int `json:"TeamAction"` // 0-创团 1-参团
}
// AsyncXPayGoodsDeliverNotifyResponse 异步通知发货响应参数
type AsyncXPayGoodsDeliverNotifyResponse struct {
util.CommonError
@@ -417,6 +427,438 @@ type AsyncXPayCoinPayNotifyResponse struct {
util.CommonError
}
// AsyncXPayRefundNotifyRequest 异步通知退款推送,请求参数
type AsyncXPayRefundNotifyRequest struct {
ToUserName string `json:"ToUserName"` // 小程序的原始 ID
FromUserName string `json:"FromUserName"` // 发送方帐号(一个 OpenID)
CreateTime int `json:"CreateTime"` // 消息发送时间(整型)
MsgType string `json:"MsgType"` // 消息类型,此时固定为:event
Event string `json:"Event"` // 事件类型,此时固定为:xpay_refund_notify
Openid string `json:"openid"` // 用户 openid
WxRefundID string `json:"WxRefundId"` // 微信退款单号
MchRefundID string `json:"MchRefundId"` // 商户退款单号
WxOrderID string `json:"WxOrderId"` // 退款单对应支付单的微信单号
MchOrderID string `json:"MchOrderId"` // 退款单对应支付单的商户单号
RefundFee int `json:"RefundFee"` // 退款金额,单位分
RetCode int `json:"RetCode"` // 退款结果,0 为成功,非0 为失败
RetMsg string `json:"RetMsg"` // 退款结果详情
RefundStartTimestamp int64 `json:"RefundStartTimestamp"` // 开始退款时间,秒级时间戳
RefundSuccTimestamp int64 `json:"RefundSuccTimestamp"` // 结束退款时间,秒级时间戳
WxpayRefundTransactionID string `json:"WxpayRefundTransactionId"` // 退款单的微信支付单号
RetryTimes int `json:"RetryTimes"` // 重试次数,从 0 开始
TeamInfo *TeamInfo `json:"TeamInfo"` // 拼团信息
}
// AsyncXPayRefundNotifyResponse 异步通知退款推送响应参数
type AsyncXPayRefundNotifyResponse struct {
util.CommonError
}
// AsyncXPaySubscribeIosRefundQueryNotifyRequest iOS Apple 支付退款问询消息
// 文档:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment/ios.html
type AsyncXPaySubscribeIosRefundQueryNotifyRequest struct {
ToUserName string `json:"ToUserName"` // 小程序的原始 ID
FromUserName string `json:"FromUserName"` // 发送方帐号
CreateTime int `json:"CreateTime"` // 消息发送时间
MsgType string `json:"MsgType"` // 消息类型,此时固定为 event
Event string `json:"Event"` // 事件类型,此时固定为 xpay_subscribe_ios_refund_query_notify
RefundTime string `json:"refund_time"` // 问询时间,Unix 时间戳
OrderTime string `json:"order_time"` // 该笔退款的订单时间,Unix 时间戳
ChannelBill string `json:"channel_bill"` // Apple 支付票据号
BundleID string `json:"bundleid"` // 应用的 Apple bundleid
ProductID string `json:"product_id"` // 道具 id
PCount string `json:"p_count"` // 道具/代币数量
RefundRequestReason string `json:"refund_request_reason"` // 用户请求退款的原因
ProvideStatus string `json:"provide_status"` // 发货状态,0:未发货 1:已发货 2:发货中
PayOrderID string `json:"pay_order_id"` // 退款对应支付订单号
}
// AsyncXPaySubscribeIosRefundQueryNotifyResponse iOS Apple 支付退款问询应答
type AsyncXPaySubscribeIosRefundQueryNotifyResponse struct {
ResultCode int32 `json:"result_code"` // 结果码,0-放过,建议退款;1-拦截,拒绝退款
ResultInfo string `json:"result_info"` // 结果描述
Evidence string `json:"evidence"` // 决策凭据,必填,用于退款审计
}
// AsyncXPayComplaintNotifyRequest 异步通知用户投诉推送,请求参数
type AsyncXPayComplaintNotifyRequest struct {
ToUserName string `json:"ToUserName"` // 小程序的原始 ID
FromUserName string `json:"FromUserName"` // 发送方帐号(一个 OpenID)
CreateTime int `json:"CreateTime"` // 消息发送时间(整型)
MsgType string `json:"MsgType"` // 消息类型,此时固定为:event
Event string `json:"Event"` // 事件类型,此时固定为:xpay_complaint_notify
Openid string `json:"openid"` // 用户 openid
WxOrderID string `json:"WxOrderId"` // 微信单号
MchOrderID string `json:"MchOrderId"` // 商户单号
TransactionID string `json:"TransactionId"` // 微信支付交易单号
ComplaintID string `json:"ComplaintId"` // 投诉单号
ComplaintDetail string `json:"ComplaintDetail"` // 投诉详情
ComplaintTime int64 `json:"ComplaintTime"` // 投诉时间,秒级时间戳
RetryTimes int `json:"RetryTimes"` // 重试次数,从 0 开始
RequestID string `json:"RequestId"` // 请求编号
}
// AsyncXPayComplaintNotifyResponse 异步通知用户投诉推送响应参数
type AsyncXPayComplaintNotifyResponse struct {
util.CommonError
}
// ==================== 下载订单 ====================
// StartDownloadOrderRequest 发起下载小程序订单明细任务,请求参数
type StartDownloadOrderRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
BeginDs string `json:"begin_ds"` // 账单开始日期,格式为 yyyymmdd
EndDs string `json:"end_ds"` // 账单结束日期,格式为 yyyymmdd
}
// StartDownloadOrderResponse 发起下载小程序订单明细任务 响应参数
type StartDownloadOrderResponse struct {
util.CommonError
}
// QueryDownloadOrderRequest 查询下载订单任务结果,请求参数
type QueryDownloadOrderRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
BeginDs string `json:"begin_ds"` // 账单开始日期,格式为 yyyymmdd
EndDs string `json:"end_ds"` // 账单结束日期,格式为 yyyymmdd
}
// QueryDownloadOrderResponse 查询下载订单任务结果 响应参数
type QueryDownloadOrderResponse struct {
util.CommonError
URL string `json:"url"` // 订单下载地址
}
// ==================== 资金管理 ====================
// QueryBizBalanceRequest 查询商家账户可提现余额,请求参数
type QueryBizBalanceRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境(仅作为签名校验,查询结果都是正式环境的)
}
// BizBalanceAvailable 可提现余额
type BizBalanceAvailable struct {
Amount string `json:"amount"` // 可提现余额,单位元
CurrencyCode string `json:"currency_code"` // 币种(一般为 CNY)
}
// QueryBizBalanceResponse 查询商家账户可提现余额 响应参数
type QueryBizBalanceResponse struct {
util.CommonError
BalanceAvailable *BizBalanceAvailable `json:"balance_available"` // 可提现余额
}
// ==================== 广告金 ====================
// TransferAccount 广告金充值账户信息
type TransferAccount struct {
TransferAccountName string `json:"transfer_account_name"` // 账户名称
TransferAccountUID int64 `json:"transfer_account_uid"` // 账户 UID
TransferAccountAgencyID int64 `json:"transfer_account_agency_id"` // 代理商 ID
TransferAccountAgencyName string `json:"transfer_account_agency_name"` // 代理商名称
State int `json:"state"` // 审核状态 0-待审核 1-已通过 2-已驳回
BindResult int `json:"bind_result"` // 绑定结果 1-绑定成功 2-绑定失败
ErrorMsg string `json:"error_msg"` // 错误信息
}
// QueryTransferAccountRequest 查询广告金充值账户,请求参数
type QueryTransferAccountRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
}
// QueryTransferAccountResponse 查询广告金充值账户 响应参数
type QueryTransferAccountResponse struct {
util.CommonError
AcctList []*TransferAccount `json:"acct_list"` // 充值账户列表
}
// AdverFundsFilter 广告金发放记录查询过滤条件
type AdverFundsFilter struct {
SettleBegin int64 `json:"settle_begin,omitempty"` // 结算周期开始时间,unix 秒级时间戳
SettleEnd int64 `json:"settle_end,omitempty"` // 结算周期结束时间,unix 秒级时间戳
FundType int `json:"fund_type,omitempty"` // 资金类型 0-普通赠送 1-广告激励 2-定向激励
}
// AdverFundsRecord 广告金发放记录
type AdverFundsRecord struct {
SettleBegin int64 `json:"settle_begin"` // 结算周期开始时间,unix 秒级时间戳
SettleEnd int64 `json:"settle_end"` // 结算周期结束时间,unix 秒级时间戳
TotalAmount int64 `json:"total_amount"` // 发放总金额,单位分
RemainAmount int64 `json:"remain_amount"` // 剩余可用金额,单位分
ExpireTime int64 `json:"expire_time"` // 过期时间,unix 秒级时间戳
FundType int `json:"fund_type"` // 资金类型 0-普通赠送 1-广告激励 2-定向激励
FundID string `json:"fund_id"` // 广告金发放 ID
}
// QueryAdverFundsRequest 查询广告金发放记录,请求参数
type QueryAdverFundsRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
Page int `json:"page,omitempty"` // 页码,>= 1
PageSize int `json:"page_size,omitempty"` // 每页记录数
Filter *AdverFundsFilter `json:"filter,omitempty"` // 查询过滤条件
}
// QueryAdverFundsResponse 查询广告金发放记录 响应参数
type QueryAdverFundsResponse struct {
util.CommonError
AdverFundsList []*AdverFundsRecord `json:"adver_funds_list"` // 发放记录列表
TotalPage int `json:"total_page"` // 总页数
}
// CreateFundsBillRequest 充值广告金,请求参数
type CreateFundsBillRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
TransferAmount int64 `json:"transfer_amount"` // 充值金额,单位分
TransferAccountUID int64 `json:"transfer_account_uid"` // 充值账户 UID
TransferAccountName string `json:"transfer_account_name"` // 充值账户名称
TransferAccountAgencyID int64 `json:"transfer_account_agency_id"` // 代理商 ID
RequestID string `json:"request_id"` // 幂等请求 ID,最长 1024 字符
SettleBegin int64 `json:"settle_begin"` // 结算周期开始时间,unix 秒级时间戳
SettleEnd int64 `json:"settle_end"` // 结算周期结束时间,unix 秒级时间戳
AuthorizeAdvertise int `json:"authorize_advertise"` // 是否授权广告数据 0-否 1-是
FundType int `json:"fund_type"` // 资金类型 0-普通赠送 1-广告激励 2-定向激励
}
// CreateFundsBillResponse 充值广告金 响应参数
type CreateFundsBillResponse struct {
util.CommonError
BillID string `json:"bill_id"` // 充值订单号
}
// BindTransferAccountRequest 绑定广告金充值账户,请求参数
type BindTransferAccountRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
TransferAccountUID int64 `json:"transfer_account_uid,omitempty"` // 充值账户 UID
TransferAccountOrgName string `json:"transfer_account_org_name,omitempty"` // 充值账户主体名称
}
// BindTransferAccountResponse 绑定广告金充值账户 响应参数
type BindTransferAccountResponse struct {
util.CommonError
}
// FundsBillFilter 广告金充值记录查询过滤条件
type FundsBillFilter struct {
OperTimeBegin int64 `json:"oper_time_begin"` // 充值开始时间,unix 秒级时间戳
OperTimeEnd int64 `json:"oper_time_end"` // 充值结束时间,unix 秒级时间戳
BillID string `json:"bill_id,omitempty"` // 充值订单号
RequestID string `json:"request_id,omitempty"` // 幂等请求 ID
}
// FundsBillRecord 广告金充值记录
type FundsBillRecord struct {
BillID string `json:"bill_id"` // 充值订单号
OperTime int64 `json:"oper_time"` // 充值时间,unix 秒级时间戳
SettleBegin int64 `json:"settle_begin"` // 对应广告金结算开始时间,unix 秒级时间戳
SettleEnd int64 `json:"settle_end"` // 对应广告金结算结束时间,unix 秒级时间戳
FundID string `json:"fund_id"` // 对应广告金发放 ID
TransferAccountName string `json:"transfer_account_name"` // 充值账户名称
TransferAccountUID int64 `json:"transfer_account_uid"` // 充值账户 UID
TransferAmount int64 `json:"transfer_amount"` // 充值金额,单位分
Status int `json:"status"` // 状态 0-充值中 1-成功 2-失败
RequestID string `json:"request_id"` // 幂等请求 ID
}
// QueryFundsBillRequest 查询广告金充值记录,请求参数
type QueryFundsBillRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
Page int `json:"page"` // 页码,>= 1
PageSize int `json:"page_size"` // 每页记录数
Filter FundsBillFilter `json:"filter"` // 查询过滤条件
}
// QueryFundsBillResponse 查询广告金充值记录 响应参数
type QueryFundsBillResponse struct {
util.CommonError
BillList []*FundsBillRecord `json:"bill_list"` // 充值记录列表
TotalPage int `json:"total_page"` // 总页数
}
// RecoverBillFilter 广告金回收记录查询过滤条件
type RecoverBillFilter struct {
RecoverTimeBegin int64 `json:"recover_time_begin"` // 回收开始时间,unix 秒级时间戳
RecoverTimeEnd int64 `json:"recover_time_end"` // 回收结束时间,unix 秒级时间戳
BillID string `json:"bill_id,omitempty"` // 回收订单号
}
// RecoverBillRecord 广告金回收记录
type RecoverBillRecord struct {
BillID string `json:"bill_id"` // 回收订单号
RecoverTime int64 `json:"recover_time"` // 回收时间,unix 秒级时间戳
SettleBegin int64 `json:"settle_begin"` // 结算周期开始时间,unix 秒级时间戳
SettleEnd int64 `json:"settle_end"` // 结算周期结束时间,unix 秒级时间戳
FundID string `json:"fund_id"` // 对应广告金发放 ID
RecoverAccountName string `json:"recover_account_name"` // 回收账户
RecoverAmount int64 `json:"recover_amount"` // 回收金额,单位分
RefundOrderList []string `json:"refund_order_list"` // 对应的退款订单号列表
}
// QueryRecoverBillRequest 查询广告金回收记录,请求参数
type QueryRecoverBillRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
Page int `json:"page"` // 页码,>= 1
PageSize int `json:"page_size"` // 每页记录数
Filter RecoverBillFilter `json:"filter"` // 查询过滤条件
}
// QueryRecoverBillResponse 查询广告金回收记录 响应参数
type QueryRecoverBillResponse struct {
util.CommonError
BillList []*RecoverBillRecord `json:"bill_list"` // 回收记录列表
TotalPage int `json:"total_page"` // 总页数
}
// DownloadAdverFundsOrderRequest 下载广告金对应的商户订单信息,请求参数
type DownloadAdverFundsOrderRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
FundID string `json:"fund_id"` // 广告金发放 ID
}
// DownloadAdverFundsOrderResponse 下载广告金对应的商户订单信息 响应参数
type DownloadAdverFundsOrderResponse struct {
util.CommonError
URL string `json:"url"` // 订单下载地址
}
// ==================== 投诉处理 ====================
// ComplaintOrderInfo 投诉关联订单信息
type ComplaintOrderInfo struct {
TransactionID string `json:"transaction_id"` // 微信支付交易单号
MchOrderNo string `json:"mch_order_no"` // 商户订单号
RefundID string `json:"refund_id"` // 退款订单号
}
// ComplaintMedia 投诉媒体信息
type ComplaintMedia struct {
MediaType int `json:"media_type"` // 媒体类型
MediaURL string `json:"media_url"` // 媒体 URL
MediaTags []string `json:"media_tags"` // 媒体标签
}
// ComplaintItem 投诉详情
type ComplaintItem struct {
ComplaintID string `json:"complaint_id"` // 投诉单号
ComplaintTime string `json:"complaint_time"` // 投诉时间
ComplaintDetail string `json:"complaint_detail"` // 投诉内容
ComplaintState string `json:"complaint_state"` // 投诉状态 PENDING/PROCESSING/PROCESSED
PayerPhone string `json:"payer_phone"` // 投诉人联系方式
PayerOpenID string `json:"payer_openid"` // 投诉人 OpenID
ComplaintOrderInfo []*ComplaintOrderInfo `json:"complaint_order_info"` // 关联订单信息
ComplaintFullRefunded bool `json:"complaint_full_refunded"` // 是否全部退款
IncomingUserResponse bool `json:"incoming_user_response"` // 是否有待处理的用户消息
UserComplaintTimes int `json:"user_complaint_times"` // 用户投诉次数
ComplaintMediaList []*ComplaintMedia `json:"complaint_media_list"` // 用户上传的证据
}
// GetComplaintListRequest 获取投诉列表,请求参数
type GetComplaintListRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
BeginDate string `json:"begin_date"` // 查询开始日期,格式 yyyy-mm-dd
EndDate string `json:"end_date"` // 查询结束日期,格式 yyyy-mm-dd
Offset int `json:"offset"` // 偏移量,从 0 开始
Limit int `json:"limit"` // 最大返回记录数
}
// GetComplaintListResponse 获取投诉列表 响应参数
type GetComplaintListResponse struct {
util.CommonError
Total int `json:"total"` // 总数
Complaints []*ComplaintItem `json:"complaints"` // 投诉列表
}
// GetComplaintDetailRequest 获取投诉详情,请求参数
type GetComplaintDetailRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
ComplaintID string `json:"complaint_id"` // 投诉单号
}
// GetComplaintDetailResponse 获取投诉详情 响应参数
type GetComplaintDetailResponse struct {
util.CommonError
Complaint *ComplaintItem `json:"complaint"` // 投诉详情
}
// NegotiationHistory 协商历史记录
type NegotiationHistory struct {
LogID string `json:"log_id"` // 操作流水号
Operator string `json:"operator"` // 操作人
OperateTime string `json:"operate_time"` // 操作时间
OperateType string `json:"operate_type"` // 操作类型
OperateDetails string `json:"operate_details"` // 操作详情
ComplaintMediaList []*ComplaintMedia `json:"complaint_media_list"` // 上传的证据
}
// GetNegotiationHistoryRequest 获取协商历史,请求参数
type GetNegotiationHistoryRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
ComplaintID string `json:"complaint_id"` // 投诉单号
Offset int `json:"offset"` // 偏移量,从 0 开始
Limit int `json:"limit"` // 最大返回记录数
}
// GetNegotiationHistoryResponse 获取协商历史 响应参数
type GetNegotiationHistoryResponse struct {
util.CommonError
Total int `json:"total"` // 总数
History []*NegotiationHistory `json:"history"` // 协商历史列表
}
// ResponseComplaintRequest 回复用户投诉,请求参数
type ResponseComplaintRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
ComplaintID string `json:"complaint_id"` // 投诉单号
ResponseContent string `json:"response_content"` // 回复内容
ResponseImages []string `json:"response_images"` // 图片文件 ID 列表(来自 upload_vp_file)
}
// ResponseComplaintResponse 回复用户投诉 响应参数
type ResponseComplaintResponse struct {
util.CommonError
}
// CompleteComplaintRequest 完成投诉处理,请求参数
type CompleteComplaintRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
ComplaintID string `json:"complaint_id"` // 投诉单号
}
// CompleteComplaintResponse 完成投诉处理 响应参数
type CompleteComplaintResponse struct {
util.CommonError
}
// UploadVPFileRequest 上传媒体文件,请求参数
type UploadVPFileRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
Base64Img string `json:"base64_img,omitempty"` // Base64 编码的图片,最大 1MB
ImgURL string `json:"img_url,omitempty"` // 图片 URL(可直接下载,不支持 302 跳转),最大 2MB,优先使用此字段
FileName string `json:"file_name"` // 图片名称
}
// UploadVPFileResponse 上传媒体文件 响应参数
type UploadVPFileResponse struct {
util.CommonError
FileID string `json:"file_id"` // 返回的文件 ID(用于 response_complaint)
}
// GetUploadFileSignRequest 获取微信支付反馈投诉图片的签名头部,请求参数
type GetUploadFileSignRequest struct {
Env Env `json:"env"` // 环境 0-正式环境 1-沙箱环境
WxpayURL string `json:"wxpay_url"` // 微信支付图片 URL,格式 "https://api.mch.weixin.qq.com/v3/merchant-service/images/{xxxxxx}"
ConvertCOS bool `json:"convert_cos"` // 是否转换为 COS,获取临时下载链接(有效期 30 分钟)
ComplaintID string `json:"complaint_id"` // 对应的投诉单号
}
// GetUploadFileSignResponse 获取微信支付反馈投诉图片的签名头部 响应参数
type GetUploadFileSignResponse struct {
util.CommonError
Sign string `json:"sign"` // Authorization 头部值
CosURL string `json:"cos_url"` // 当 convert_cos=true 时返回的 COS URL,有效期 30 分钟
}
// URLParams url parameter
type URLParams struct {
Path string `json:"path"`
+522 -28
View File
@@ -55,7 +55,7 @@ func (s *VirtualPayment) QueryUserBalance(ctx context.Context, in *QueryUserBala
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -83,7 +83,7 @@ func (s *VirtualPayment) CurrencyPay(ctx context.Context, in *CurrencyPayRequest
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -111,7 +111,7 @@ func (s *VirtualPayment) QueryOrder(ctx context.Context, in *QueryOrderRequest)
return
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -139,7 +139,7 @@ func (s *VirtualPayment) CancelCurrencyPay(ctx context.Context, in *CancelCurren
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -169,7 +169,7 @@ func (s *VirtualPayment) NotifyProvideGoods(ctx context.Context, in *NotifyProvi
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -198,7 +198,7 @@ func (s *VirtualPayment) PresentCurrency(ctx context.Context, in *PresentCurrenc
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -227,7 +227,7 @@ func (s *VirtualPayment) DownloadBill(ctx context.Context, in *DownloadBillReque
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -256,7 +256,7 @@ func (s *VirtualPayment) RefundOrder(ctx context.Context, in *RefundOrderRequest
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -285,7 +285,7 @@ func (s *VirtualPayment) CreateWithdrawOrder(ctx context.Context, in *CreateWith
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -314,7 +314,7 @@ func (s *VirtualPayment) QueryWithdrawOrder(ctx context.Context, in *QueryWithdr
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -343,7 +343,7 @@ func (s *VirtualPayment) StartUploadGoods(ctx context.Context, in *StartUploadGo
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -372,7 +372,7 @@ func (s *VirtualPayment) QueryUploadGoods(ctx context.Context, in *QueryUploadGo
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -401,7 +401,7 @@ func (s *VirtualPayment) StartPublishGoods(ctx context.Context, in *StartPublish
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -430,7 +430,7 @@ func (s *VirtualPayment) QueryPublishGoods(ctx context.Context, in *QueryPublish
}
var response []byte
if response, err = util.PostJSONContext(ctx, address, in); err != nil {
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
@@ -439,6 +439,499 @@ func (s *VirtualPayment) QueryPublishGoods(ctx context.Context, in *QueryPublish
return
}
// StartDownloadOrder 发起下载小程序订单明细任务
func (s *VirtualPayment) StartDownloadOrder(ctx context.Context, in *StartDownloadOrderRequest) (out StartDownloadOrderResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: startDownloadOrder,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "StartDownloadOrder")
return
}
// QueryDownloadOrder 查询下载订单任务结果
func (s *VirtualPayment) QueryDownloadOrder(ctx context.Context, in *QueryDownloadOrderRequest) (out QueryDownloadOrderResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: queryDownloadOrder,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "QueryDownloadOrder")
return
}
// QueryBizBalance 查询商家账户可提现余额
func (s *VirtualPayment) QueryBizBalance(ctx context.Context, in *QueryBizBalanceRequest) (out QueryBizBalanceResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: queryBizBalance,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "QueryBizBalance")
return
}
// QueryTransferAccount 查询广告金充值账户
func (s *VirtualPayment) QueryTransferAccount(ctx context.Context, in *QueryTransferAccountRequest) (out QueryTransferAccountResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: queryTransferAccount,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "QueryTransferAccount")
return
}
// QueryAdverFunds 查询广告金发放记录
func (s *VirtualPayment) QueryAdverFunds(ctx context.Context, in *QueryAdverFundsRequest) (out QueryAdverFundsResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: queryAdverFunds,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "QueryAdverFunds")
return
}
// CreateFundsBill 充值广告金
func (s *VirtualPayment) CreateFundsBill(ctx context.Context, in *CreateFundsBillRequest) (out CreateFundsBillResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: createFundsBill,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "CreateFundsBill")
return
}
// BindTransferAccount 绑定广告金充值账户
func (s *VirtualPayment) BindTransferAccount(ctx context.Context, in *BindTransferAccountRequest) (out BindTransferAccountResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: bindTransferAccount,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "BindTransferAccount")
return
}
// QueryFundsBill 查询广告金充值记录
func (s *VirtualPayment) QueryFundsBill(ctx context.Context, in *QueryFundsBillRequest) (out QueryFundsBillResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: queryFundsBill,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "QueryFundsBill")
return
}
// QueryRecoverBill 查询广告金回收记录
func (s *VirtualPayment) QueryRecoverBill(ctx context.Context, in *QueryRecoverBillRequest) (out QueryRecoverBillResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: queryRecoverBill,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "QueryRecoverBill")
return
}
// DownloadAdverFundsOrder 下载广告金对应的商户订单信息
func (s *VirtualPayment) DownloadAdverFundsOrder(ctx context.Context, in *DownloadAdverFundsOrderRequest) (out DownloadAdverFundsOrderResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: downloadAdverFundsOrder,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "DownloadAdverFundsOrder")
return
}
// GetComplaintList 获取投诉列表
func (s *VirtualPayment) GetComplaintList(ctx context.Context, in *GetComplaintListRequest) (out GetComplaintListResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: getComplaintList,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "GetComplaintList")
return
}
// GetComplaintDetail 获取投诉详情
func (s *VirtualPayment) GetComplaintDetail(ctx context.Context, in *GetComplaintDetailRequest) (out GetComplaintDetailResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: getComplaintDetail,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "GetComplaintDetail")
return
}
// GetNegotiationHistory 获取协商历史
func (s *VirtualPayment) GetNegotiationHistory(ctx context.Context, in *GetNegotiationHistoryRequest) (out GetNegotiationHistoryResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: getNegotiationHistory,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "GetNegotiationHistory")
return
}
// ResponseComplaint 回复用户
func (s *VirtualPayment) ResponseComplaint(ctx context.Context, in *ResponseComplaintRequest) (out ResponseComplaintResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: responseComplaint,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "ResponseComplaint")
return
}
// CompleteComplaint 完成投诉处理
func (s *VirtualPayment) CompleteComplaint(ctx context.Context, in *CompleteComplaintRequest) (out CompleteComplaintResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: completeComplaint,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "CompleteComplaint")
return
}
// UploadVPFile 上传媒体文件
func (s *VirtualPayment) UploadVPFile(ctx context.Context, in *UploadVPFileRequest) (out UploadVPFileResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: uploadVPFile,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "UploadVPFile")
return
}
// GetUploadFileSign 获取微信支付反馈投诉图片的签名头部
func (s *VirtualPayment) GetUploadFileSign(ctx context.Context, in *GetUploadFileSignRequest) (out GetUploadFileSignResponse, err error) {
var jsonByte []byte
if jsonByte, err = json.Marshal(in); err != nil {
return
}
var (
params = URLParams{
Path: getUploadFileSign,
Content: string(jsonByte),
Signature: EmptyString,
}
address string
)
if address, err = s.requestAddress(params); err != nil {
return
}
var response []byte
if response, err = postJSONBytesContext(ctx, address, jsonByte); err != nil {
return
}
// 使用通用方法返回错误
err = util.DecodeWithError(response, &out, "GetUploadFileSign")
return
}
// hmacSha256 hmac sha256
func (s *VirtualPayment) hmacSha256(key, data string) string {
h := hmac.New(sha256.New, []byte(key))
@@ -446,6 +939,13 @@ func (s *VirtualPayment) hmacSha256(key, data string) string {
return hex.EncodeToString(h.Sum(nil))
}
// postJSONBytesContext POST JSON bytes with context
func postJSONBytesContext(ctx context.Context, address string, jsonByte []byte) ([]byte, error) {
return util.HTTPPostContext(ctx, address, jsonByte, map[string]string{
"Content-Type": "application/json;charset=utf-8",
})
}
// PaySign pay sign
func (s *VirtualPayment) PaySign(url, data string) (string, error) {
if strings.TrimSpace(s.ctx.Config.AppKey) == "" {
@@ -476,23 +976,17 @@ func (s *VirtualPayment) PaySignature(url, data string) (paySign, signature stri
// requestURL .组合 URL
func (s *VirtualPayment) requestAddress(params URLParams) (url string, err error) {
switch params.Path {
case queryUserBalance:
case currencyPay:
case cancelCurrencyPay:
case queryUserBalance, currencyPay, cancelCurrencyPay:
if params.PaySign, params.Signature, err = s.PaySignature(params.Path, params.Content); err != nil {
return
}
case queryOrder:
case notifyProvideGoods:
case presentCurrency:
case downloadBill:
case refundOrder:
case createWithdrawOrder:
case queryWithdrawOrder:
case startUploadGoods:
case queryUploadGoods:
case startPublishGoods:
case queryPublishGoods:
case queryOrder, notifyProvideGoods, presentCurrency, downloadBill, refundOrder,
createWithdrawOrder, queryWithdrawOrder, startUploadGoods, queryUploadGoods,
startPublishGoods, queryPublishGoods, startDownloadOrder, queryDownloadOrder,
queryBizBalance, queryTransferAccount, queryAdverFunds, createFundsBill,
bindTransferAccount, queryFundsBill, queryRecoverBill, downloadAdverFundsOrder,
getComplaintList, getComplaintDetail, getNegotiationHistory, responseComplaint,
completeComplaint, uploadVPFile, getUploadFileSign:
if params.PaySign, err = s.PaySign(params.Path, params.Content); err != nil {
return
}
+10
View File
@@ -6,6 +6,8 @@ import (
log "github.com/sirupsen/logrus"
"github.com/silenceper/wechat/v2/aispeech"
aispeechConfig "github.com/silenceper/wechat/v2/aispeech/config"
"github.com/silenceper/wechat/v2/cache"
"github.com/silenceper/wechat/v2/miniprogram"
miniConfig "github.com/silenceper/wechat/v2/miniprogram/config"
@@ -84,6 +86,14 @@ func (wc *Wechat) GetWork(cfg *workConfig.Config) *work.Work {
return work.NewWork(cfg)
}
// GetAISpeech 获取微信智能对话的实例
func (wc *Wechat) GetAISpeech(cfg *aispeechConfig.Config) *aispeech.AISpeech {
if cfg.Cache == nil {
cfg.Cache = wc.cache
}
return aispeech.NewAISpeech(cfg)
}
// SetHTTPClient 设置HTTPClient
func (wc *Wechat) SetHTTPClient(client *http.Client) {
util.DefaultHTTPClient = client
+10 -10
View File
@@ -187,16 +187,16 @@ type ExternalUserForBatch struct {
// ExternalContact 批量获取外部联系人用户信息
type ExternalContact struct {
ExternalUserID string `json:"external_userid"`
Name string `json:"name"`
Position string `json:"position"`
Avatar string `json:"avatar"`
CorpName string `json:"corp_name"`
CorpFullName string `json:"corp_full_name"`
Type int64 `json:"type"`
Gender int64 `json:"gender"`
UnionID string `json:"unionid"`
ExternalProfile string `json:"external_profile"`
ExternalUserID string `json:"external_userid"`
Name string `json:"name"`
Position string `json:"position"`
Avatar string `json:"avatar"`
CorpName string `json:"corp_name"`
CorpFullName string `json:"corp_full_name"`
Type int64 `json:"type"`
Gender int64 `json:"gender"`
UnionID string `json:"unionid"`
ExternalProfile *ExternalProfile `json:"external_profile"`
}
// FollowInfo 批量获取外部联系人跟进人信息
+21
View File
@@ -14,6 +14,24 @@ const (
// CustomerBatchGetOptions 客户基本信息获取请求参数
type CustomerBatchGetOptions struct {
ExternalUserIDList []string `json:"external_userid_list"` // external_userid列表
// NeedEnterSessionContext 是否需要返回客户48小时内最后一次进入会话的上下文信息
// 0-不返回 1-返回。默认不返回
NeedEnterSessionContext int `json:"need_enter_session_context"`
}
// CustomerWechatChannelsSchema 进入会话的视频号信息,从视频号进入会话才有值
type CustomerWechatChannelsSchema struct {
Nickname string `json:"nickname"` // 视频号名称,视频号场景值为1、2、3时返回此项
ShopNickname string `json:"shop_nickname"` // 视频号小店名称,视频号场景值为4、5时返回此项
Scene uint32 `json:"scene"` // 视频号场景值。1:视频号主页,2:视频号直播间商品列表页,3:视频号商品橱窗页,4:视频号小店商品详情页,5:视频号小店订单页
}
// CustomerEnterSessionContextSchema 48小时内最后一次进入会话的上下文信息
// 请求的need_enter_session_context参数设置为1才返回
type CustomerEnterSessionContextSchema struct {
Scene string `json:"scene"` // 进入会话的场景值,获取客服账号链接开发者自定义的场景值
SceneParam string `json:"scene_param"` // 进入会话的自定义参数,获取客服账号链接返回的url,开发者按规范拼接的scene_param参数
WechatChannels CustomerWechatChannelsSchema `json:"wechat_channels"` // 进入会话的视频号信息,从视频号进入会话才有值
}
// CustomerSchema 微信客户基本资料
@@ -23,6 +41,9 @@ type CustomerSchema struct {
Avatar string `json:"avatar"` // 微信头像。第三方不可获取
Gender int `json:"gender"` // 性别
UnionID string `json:"unionid"` // unionid,需要绑定微信开发者帐号才能获取到,查看绑定方法: https://open.work.weixin.qq.com/kf/doc/92512/93143/94769#%E5%A6%82%E4%BD%95%E8%8E%B7%E5%8F%96%E5%BE%AE%E4%BF%A1%E5%AE%A2%E6%88%B7%E7%9A%84unionid
// EnterSessionContext 48小时内最后一次进入会话的上下文信息
// 请求的need_enter_session_context参数设置为1才返回
EnterSessionContext CustomerEnterSessionContextSchema `json:"enter_session_context"`
}
// CustomerBatchGetSchema 获取客户基本信息响应内容