Skip to content

Commit

Permalink
add douyin pay, resolved go-pay#229
Browse files Browse the repository at this point in the history
  • Loading branch information
songqj committed Apr 8, 2024
1 parent afa5312 commit c19f568
Show file tree
Hide file tree
Showing 14 changed files with 711 additions and 0 deletions.
22 changes: 22 additions & 0 deletions douyin/access_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package douyin

import (
"context"

"github.com/go-pay/gopay"
)

// https://developer.open-douyin.com/docs/resource/zh-CN/mini-app/develop/server/interface-request-credential/non-user-authorization/get-access-token
func (u *Client) GetAccessToken(ctx context.Context) (resp *AccessTokenResp, err error) {
bm := make(gopay.BodyMap)
bm.Set("appid", u.AppId).
Set("secret", u.ApiKey).
Set("grant_type", "client_credential")

resp = new(AccessTokenResp)
if _, err = u.hc.Req().Post(u.BaseURL+"/api/apps/v2/token").SendBodyMap(bm).EndStruct(ctx, resp); err != nil {
return nil, err
}

return resp, nil
}
24 changes: 24 additions & 0 deletions douyin/censor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package douyin

import (
"context"
"encoding/json"
"fmt"

"github.com/go-pay/gopay"
)

// https://developer.open-douyin.com/docs/resource/zh-CN/mini-app/develop/server/content-security/picture-detect-v2
// 图片检测V2
func (u *Client) CensorImage(ctx context.Context, bm gopay.BodyMap) (resp *CensorImgResponse, err error) {
var bs []byte
if bs, err = u.doPost(ctx, "/api/apps/censor/image", bm); err != nil {
return nil, err
}
resp = new(CensorImgResponse)
if err = json.Unmarshal(bs, resp); err != nil {
fmt.Printf("err:%s\n\n\n", err)
return nil, fmt.Errorf("[%w], bytes: %s", gopay.UnmarshalErr, string(bs))
}
return
}
14 changes: 14 additions & 0 deletions douyin/censor_model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package douyin

type CensorImgResponse struct {
ErrCode int `json:"error,omitempty"`
ErrMsg string `json:"message,omitempty"`
Predicts []Predict `json:"predicts,omitempty"`
ModelName string `json:"model_name,omitempty"`
Hit bool `json:"hit,omitempty"`
}

type Predict struct {
ModelName string `json:"model_name,omitempty"`
Hit bool `json:"hit,omitempty"`
}
23 changes: 23 additions & 0 deletions douyin/censor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package douyin

import (
"testing"

"github.com/go-pay/gopay"
)

func TestClient_CensorImage(t *testing.T) {
ast, err := client.GetAccessToken(ctx)
if err != nil {
t.Log(err)
}
bm := make(gopay.BodyMap)
bm.Set("access_token", ast.Data.AccessToken)
bm.Set("image", "http://xxx.jpg")
resp, err := client.CensorImage(ctx, bm)
if err != nil {
t.Logf("fail:%v", err)
return
}
t.Logf("resp:%+v, \nerr:%s", resp, err)
}
181 changes: 181 additions & 0 deletions douyin/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package douyin

import (
"context"
"crypto"
"crypto/hmac"
"crypto/md5"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"hash"
"net/http"
"sort"
"strings"
"sync"

"github.com/go-pay/gopay"
"github.com/go-pay/xhttp"
"github.com/go-pay/xlog"
)

// Client douyin
type Client struct {
AppId string
MchId string
ApiKey string
BaseURL string
NotifyUrl string
Salt string // 盐值
Token string // 支付设置 token
IsProd bool // 是否生产环境
ctx context.Context // 上下文
DebugSwitch gopay.DebugSwitch // 调试开关,是否打印日志
mu sync.RWMutex
sha256Hash hash.Hash
md5Hash hash.Hash
hc *xhttp.Client
tlsHc *xhttp.Client
}

// NewClient 初始化抖音支付客户端
// appId:应用ID
// mchId:商户ID
// ApiKey:API秘钥值
// IsProd:是否是正式环境
func NewClient(appId, mchId, apiKey string, isProd bool) (client *Client) {
return &Client{
AppId: appId,
MchId: mchId,
ApiKey: apiKey,
IsProd: isProd,
ctx: context.Background(),
DebugSwitch: gopay.DebugOff,
sha256Hash: hmac.New(sha256.New, []byte(apiKey)),
md5Hash: md5.New(),
hc: xhttp.NewClient(),
tlsHc: xhttp.NewClient(),
}
}

// SetBodySize 设置http response body size(MB)
func (c *Client) SetBodySize(sizeMB int) {
if sizeMB > 0 {
c.hc.SetBodySize(sizeMB)
}
}

func (c *Client) SetToken(token string) {
c.Token = token
}

// 担保支付回调签名算法
// 参数:"strArr" 所有字段(验证时注意不包含 sign 签名本身,不包含空字段与 type 常量字段)内容与平台上配置的 token
func (c *Client) VerifySign(notifyReq *NotifyRequest) (err error) {
strArr := []string{c.Token, notifyReq.Timestamp, notifyReq.Nonce, notifyReq.Msg}
sort.Strings(strArr)
h := sha1.New()
h.Write([]byte(strings.Join(strArr, "")))
validSign := fmt.Sprintf("%x", h.Sum(nil))

if notifyReq.MsgSignature != validSign {
return fmt.Errorf("签名验证失败")
}
return
}

// 获取签名字符串
func (c *Client) getRsaSign(bm gopay.BodyMap) (sign string, err error) {
var paramsArr []string
if c.Salt == "" {
return "", fmt.Errorf("签名缺少必要的参数")
}
for k, v := range bm {
if k == "other_settle_params" || k == "app_id" || k == "thirdparty_id" || k == "sign" {
continue
}
value := strings.TrimSpace(fmt.Sprintf("%v", v))
if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") && len(value) > 1 {
value = value[1 : len(value)-1]
}
value = strings.TrimSpace(value)
if value == "" || value == "null" {
continue
}
paramsArr = append(paramsArr, value)
}
paramsArr = append(paramsArr, c.Salt)
sort.Strings(paramsArr)
c.mu.Lock()
defer func() {
c.md5Hash.Reset()
c.mu.Unlock()
}()
c.md5Hash.Write([]byte(strings.Join(paramsArr, "&")))

return fmt.Sprintf("%x", c.md5Hash.Sum(nil)), nil
}

// POST 发起请求
func (c *Client) doPost(ctx context.Context, path string, bm gopay.BodyMap) (bs []byte, err error) {
sign, err := c.getRsaSign(bm)
if err != nil {
return nil, fmt.Errorf("GetRsaSign Error: %w", err)
}
bm.Set("app_id", c.AppId)
bm.Set("sign", sign)
fmt.Println(sign)
req := c.hc.Req()
req.Header.Add("Accept", "application/json")
if c.DebugSwitch == gopay.DebugOn {
xlog.Debugf("DouYin_Req_Path: %s", path)
xlog.Debugf("DouYin_Req_Body: %s", bm.JsonBody())
xlog.Debugf("DouYin_Req_Headers: %#v", req.Header)
fmt.Println(bm.JsonBody())
}
res, bs, err := req.Post(c.BaseURL + path).SendBodyMap(bm).EndBytes(ctx)
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP Request Error, StatusCode = %d", res.StatusCode)
}
return bs, nil
}

// 验签示例
func DyCheckSign(timestamp, nonce, body, signature, pubKeyStr string) (bool, error) {
pubKey, err := PemToRSAPublicKey(pubKeyStr) // 注意验签时publicKey使用平台公钥而非应用公钥
if err != nil {
return false, err
}

hashed := sha256.Sum256([]byte(timestamp + "\n" + nonce + "\n" + body + "\n"))
signBytes, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
return false, err
}
err = rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hashed[:], signBytes)
return err == nil, nil
}

func PemToRSAPublicKey(pemKeyStr string) (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pemKeyStr))
if block == nil || len(block.Bytes) == 0 {
return nil, fmt.Errorf("empty block in pem string")
}
key, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
switch key := key.(type) {
case *rsa.PublicKey:
return key, nil
default:
return nil, fmt.Errorf("not rsa public key")
}
}
29 changes: 29 additions & 0 deletions douyin/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package douyin

import (
"context"
"os"
"testing"

"github.com/go-pay/gopay"
)

var (
client *Client
appId = ""
mchId = ""
apiKey = ""
salt = ""
baseUrl = "https://developer.toutiao.com"
ctx = context.Background()
token = ""
)

func TestMain(m *testing.M) {
client = NewClient(appId, mchId, apiKey, false)
client.DebugSwitch = gopay.DebugOn
client.Salt = salt
client.BaseURL = baseUrl
client.SetToken(token)
os.Exit(m.Run())
}
50 changes: 50 additions & 0 deletions douyin/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package douyin

type NotifyRequest struct {
Timestamp string `json:"timestamp,omitempty"` // UTC时间戳
Nonce string `json:"nonce,omitempty"` // 随机字符串
Msg string `json:"msg,omitempty"`
MsgSignature string `json:"msg_signature,omitempty"`
Type string `json:"type,omitempty"`
}

type NotifyResp struct {
ErrNo int `json:"err_no"`
ErrTips string `json:"err_tips"`
}

type NotifyMsgResp struct {
AppId string `json:"appid,omitempty"` // 当前交易发起的小程序id
CpOrderno string `json:"cp_orderno,omitempty"` // 开发者侧的订单号
CpExtra string `json:"cp_extra,omitempty"` // 预下单时开发者传入字段
Way string `json:"way,omitempty"` // 支付渠道 1-微信支付,2-支付宝支付,10-抖音支付
ChannelNo string `json:"channel_no,omitempty"` // 支付渠道侧单号
PaymentOrderNo string `json:"payment_order_no,omitempty"` // 支付渠道侧PC单号
TotalAmount int `json:"total_amount,omitempty"` // 支付金额,单位为分
Status string `json:"status,omitempty"` // 固定 SUCCESS
ItemId string `json:"item_id,omitempty"` // 订单来源视频对应视频 id
SellerUid string `json:"seller_uid,omitempty"` // 该笔交易卖家商户号
PaidAt int64 `json:"paid_at,omitempty"` // 支付时间,Unix 时间戳
OrderId string `json:"order_id,omitempty"` // 抖音侧订单号
}

type PushOrderResponse struct {
ErrCode int64 `json:"err_code,omitempty"` // 执行结果
ErrMsg string `json:"err_msg,omitempty"` // 返回错误信息
Body string `json:"data,omitempty"` // POI 等关联业务推送结果,非 POI 订单为空,JSON 字符串
}

type AccessTokenReq struct {
AppId string `json:"appid"`
Secret string `json:"secret"`
GrantType string `json:"grant_type"`
}

type AccessTokenResp struct {
Data struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
} `json:"data"`
ErrCode int `json:"err_no"`
ErrMsg string `json:"err_tips"`
}
38 changes: 38 additions & 0 deletions douyin/model_payment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package douyin

type CreateOrderData struct {
OrderId string `json:"order_id,omitempty"` // 抖音侧的订单号
OrderToken string `json:"order_token,omitempty"`
}

type CreateOrderResponse struct {
ErrNo int `json:"err_no,omitempty"` // 执行结果
ErrTips string `json:"err_tips,omitempty"` // 返回错误信息
Data *CreateOrderData `json:"data"`
}

type QueryOrderResponse struct {
ErrNo int `json:"err_no,omitempty"` // 执行结果
ErrTips string `json:"err_tips,omitempty"` // 返回错误信息
OutOrderNo string `json:"out_order_no,omitempty"` // 开发者侧的订单号
OrderId string `json:"order_id,omitempty"` // 抖音侧的订单号
PaymentInfo *PaymentInfoData `json:"payment_info,omitempty"` // 支付信息
CpsInfo *CpsInfoData `json:"cps_info,omitempty"` // cps信息
}

type PaymentInfoData struct {
TotalFee int `json:"total_fee,omitempty"` // 支付金额
OrderStatus string `json:"order_status,omitempty"` // 支付状态枚举值
PayTime string `json:"pay_time,omitempty"` // 支付完成时间
Way int `json:"way,omitempty"` // 支付渠道
ChannelNo string `json:"channel_no,omitempty"` // 支付渠道侧的支付单号
SellerUid string `json:"seller_uid,omitempty"` // 该笔交易卖家商户号
ItemId string `json:"item_id,omitempty"` // 订单来源视频对应视频 id
CpExtra string `json:"cp_extra,omitempty"` // 预下单时开发者传入字段
}

type CpsInfoData struct {
TotalFee string `json:"share_amount,omitempty"` // 达人分佣金额
DouyinId string `json:"douyin_id,omitempty"` // 达人抖音号
Nickname string `json:"nickname,omitempty"` // 达人昵称
}
Loading

0 comments on commit c19f568

Please sign in to comment.