mirror of
https://github.com/silenceper/wechat.git
synced 2026-11-16 05:12:03 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8409760713 | ||
|
|
ad48392a5b | ||
|
|
2bc22dc4ae | ||
|
|
0e34286d78 | ||
|
|
fb3009e337 | ||
|
|
d34af7aeeb | ||
|
|
bfbfc844ce | ||
|
|
269d93ea6f |
No files matched your search
+100
-2
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Vendored
+43
-27
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+116
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"` // 请求编号
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 请求并返回二维码二进制数据
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 批量获取外部联系人跟进人信息
|
||||
|
||||
@@ -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 获取客户基本信息响应内容
|
||||
|
||||
Reference in new issue
Block a user