first commit

This commit is contained in:
xia 2025-01-13 22:58:50 +08:00
commit 9ab243733d
8 changed files with 410 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.idea

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# douyin-openapi
抖音openApi
包含抖音小程序登录

View File

@ -0,0 +1,132 @@
package access_token
import (
"encoding/json"
"fmt"
"gitea.youtukeji.com.cn/xiabin/douyin-openapi/cache"
"gitea.youtukeji.com.cn/xiabin/douyin-openapi/util"
"sync"
"time"
)
// 正式地址
const accessTokenURL = "https://developer.toutiao.com/api/apps/v2/token"
// 沙盒地址
const sandBoxTokenURL = "https://open-sandbox.douyin.com/api/apps/v2/token"
// AccessToken 管理AccessToken 的基础接口
type AccessToken interface {
GetCacheKey() string // 获取缓存的key
SetCacheKey(key string) // 设置缓存key
GetAccessToken() (string, error) // 获取token
}
// accessTokenLock 初始化全局锁防止并发获取token
var accessTokenLock = new(sync.Mutex)
// DefaultAccessToken 默认的token管理类
type DefaultAccessToken struct {
AppId string // app_id string 是 小程序的 app_id
AppSecret string // app_secret string 是 小程序的密钥
GrantType string // grant_type string 是 固定值“client_credentials”
Cache cache.Cache // 缓存组件
accessTokenLock *sync.Mutex // 读写锁
accessTokenCacheKey string // 缓存的key
SandBox bool // 是否沙盒地址 默认 false 线上地址
}
// NewDefaultAccessToken 实例化默认的token管理类
func NewDefaultAccessToken(appId, appSecret string, cache cache.Cache, IsSandbox bool) AccessToken {
if cache == nil {
panic(any("cache is need"))
}
token := &DefaultAccessToken{
AppId: appId,
AppSecret: appSecret,
GrantType: "client_credential",
Cache: cache,
accessTokenCacheKey: fmt.Sprintf("douyin_openapi_access_token_%s", appId),
accessTokenLock: accessTokenLock,
SandBox: IsSandbox,
}
return token
}
// GetCacheKey 获取缓存key
func (dd *DefaultAccessToken) GetCacheKey() string {
return dd.accessTokenCacheKey
}
// SetCacheKey 设置缓存key
func (dd *DefaultAccessToken) SetCacheKey(key string) {
dd.accessTokenCacheKey = key
}
// GetAccessToken 获取token
func (dd *DefaultAccessToken) GetAccessToken() (string, error) {
// 先尝试从缓存中获取如果不存在就调用接口获取
if val := dd.Cache.Get(dd.GetCacheKey()); val != nil {
return val.(string), nil
}
// 加锁防止并发获取接口
dd.accessTokenLock.Lock()
defer dd.accessTokenLock.Unlock()
// 双捡防止重复获取
if val := dd.Cache.Get(dd.GetCacheKey()); val != nil {
return val.(string), nil
}
// 开始调用接口获取token
api := accessTokenURL
if dd.SandBox {
api = sandBoxTokenURL
}
reqAccessToken, err := GetTokenFromServer(api, dd.AppId, dd.AppSecret)
if err != nil {
return "", err
}
// 设置缓存
expires := reqAccessToken.Data.ExpiresIn - 1500
err = dd.Cache.Set(dd.GetCacheKey(), reqAccessToken.Data.AccessToken, time.Duration(expires)*time.Second)
if err != nil {
return "", err
}
return reqAccessToken.Data.AccessToken, nil
}
// ResAccessToken 获取token的返回结构体
type ResAccessToken struct {
ErrNo int `json:"err_no,omitempty"`
ErrTips string `json:"err_tips,omitempty"`
Data ResAccessTokenData `json:"data,omitempty"`
}
type ResAccessTokenData struct {
AccessToken string `json:"access_token,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
}
// GetTokenFromServer 从抖音服务器获取token
func GetTokenFromServer(apiUrl string, appId, appSecret string) (resAccessToken ResAccessToken, err error) {
params := map[string]interface{}{
"appid": appId,
"secret": appSecret,
"grant_type": "client_credential",
}
body, err := util.PostJSON(apiUrl, params)
if err != nil {
return
}
err = json.Unmarshal(body, &resAccessToken)
if err != nil {
return
}
if resAccessToken.ErrNo != 0 {
err = fmt.Errorf("get access_token error : errcode=%v , errormsg=%v", resAccessToken.ErrTips, resAccessToken.ErrNo)
return
}
return
}

76
cache/cache.go vendored Normal file
View File

@ -0,0 +1,76 @@
package cache
import (
"sync"
"time"
)
// Cache any
type Cache interface {
Get(key string) any
Set(key string, val any, timeout time.Duration) error
IsExist(key string) bool
Delete(key string) error
}
// data 存储数据用的
type data struct {
Data any
Expired time.Time
}
// Memory 实现一个内存缓存
type Memory struct {
sync.Map
}
var m = sync.Map{}
// NewMemory 实例化一个内存缓存器
func NewMemory() Cache {
return &Memory{
sync.Map{},
}
}
// Get 获取缓存的值
func (mem *Memory) Get(key string) any {
if val, ok := mem.Load(key); ok {
val := val.(*data)
// 判断缓存是否过期
if val.Expired.Before(time.Now()) {
// 删除这个key
_ = mem.Delete(key)
return nil
}
return val.Data
}
return nil
}
// Set 设置一个值
func (mem *Memory) Set(key string, val any, timeout time.Duration) error {
mem.Store(key, &data{
Data: val,
Expired: time.Now().Add(timeout),
})
return nil
}
// IsExist 判断值是否存在
func (mem *Memory) IsExist(key string) bool {
if val, ok := mem.Load(key); ok {
val := val.(*data)
if val.Expired.Before(time.Now()) {
return false
}
return true
}
return false
}
// Delete 删除一个值
func (mem *Memory) Delete(key string) error {
return mem.Delete(key)
}

106
douyin_openapi.go Normal file
View File

@ -0,0 +1,106 @@
package douyin_openapi
import (
"encoding/json"
"fmt"
accessToken "gitea.youtukeji.com.cn/xiabin/douyin-openapi/access-token"
"gitea.youtukeji.com.cn/xiabin/douyin-openapi/cache"
"gitea.youtukeji.com.cn/xiabin/douyin-openapi/util"
)
const (
code2Session = "/api/apps/v2/jscode2session" // 小程序登录地址
)
// DouYinOpenApiConfig 实例化配置
type DouYinOpenApiConfig struct {
AppId string
AppSecret string
AccessToken accessToken.AccessToken
Cache cache.Cache
IsSandbox bool
Token string
Salt string
}
// DouYinOpenApi 基类
type DouYinOpenApi struct {
Config DouYinOpenApiConfig
BaseApi string
}
// NewDouYinOpenApi 实例化一个抖音openapi实例
func NewDouYinOpenApi(config DouYinOpenApiConfig) *DouYinOpenApi {
if config.Cache == nil {
config.Cache = cache.NewMemory()
}
if config.AccessToken == nil {
config.AccessToken = accessToken.NewDefaultAccessToken(config.AppId, config.AppSecret, config.Cache, config.IsSandbox)
}
BaseApi := "https://developer.toutiao.com"
if config.IsSandbox {
BaseApi = "https://open-sandbox.douyin.com"
}
return &DouYinOpenApi{
Config: config,
BaseApi: BaseApi,
}
}
// GetApiUrl 获取api地址
func (d *DouYinOpenApi) GetApiUrl(url string) string {
return fmt.Sprintf("%s%s", d.BaseApi, url)
}
// PostJson 封装公共的请求方法
func (d *DouYinOpenApi) PostJson(api string, params any, response any) (err error) {
body, err := util.PostJSON(api, params)
if err != nil {
return
}
err = json.Unmarshal(body, &response)
if err != nil {
return
}
return
}
// Code2SessionParams 小程序登录 所需参数
type Code2SessionParams struct {
Appid string `json:"appid,omitempty"`
Secret string `json:"secret,omitempty"`
AnonymousCode string `json:"anonymous_code,omitempty"`
Code string `json:"code,omitempty"`
}
// Code2SessionResponse 小程序登录返回值
type Code2SessionResponse struct {
ErrNo int `json:"err_no,omitempty"`
ErrTips string `json:"err_tips,omitempty"`
Data Code2SessionResponseData `json:"data,omitempty"`
}
type Code2SessionResponseData struct {
SessionKey string `json:"session_key,omitempty"`
Openid string `json:"openid,omitempty"`
AnonymousOpenid string `json:"anonymous_openid,omitempty"`
UnionId string `json:"unionid,omitempty"`
}
// Code2Session 小程序登录
func (d *DouYinOpenApi) Code2Session(code, anonymousCode string) (code2SessionResponse Code2SessionResponse, err error) {
params := Code2SessionParams{
Appid: d.Config.AppId,
Secret: d.Config.AppSecret,
AnonymousCode: anonymousCode,
Code: code,
}
err = d.PostJson(d.GetApiUrl(code2Session), params, &code2SessionResponse)
if err != nil {
return
}
if code2SessionResponse.ErrNo != 0 {
return code2SessionResponse, fmt.Errorf("小程序登录错误: %s %d", code2SessionResponse.ErrTips, code2SessionResponse.ErrNo)
}
return
}

62
douyin_openapi_test.go Normal file
View File

@ -0,0 +1,62 @@
package douyin_openapi
import (
accessToken "gitea.youtukeji.com.cn/xiabin/douyin-openapi/access-token"
"gitea.youtukeji.com.cn/xiabin/douyin-openapi/cache"
"testing"
)
// 声明测试常量
const (
AppId = ""
AppSecret = ""
Token = ""
Salt = ""
)
// 声明一个缓存实例
var Cache cache.Cache
// 声明全局openApi实例
var OpenApi *DouYinOpenApi
func init() {
Cache = cache.NewMemory()
OpenApi = NewDouYinOpenApi(DouYinOpenApiConfig{
AppId: AppId,
AppSecret: AppSecret,
IsSandbox: false,
Token: Token,
Salt: Salt,
})
}
// 测试获取新的token
func TestDouyinOpenapi_NewDefaultAccessToken(t *testing.T) {
token := accessToken.NewDefaultAccessToken(AppId, AppSecret, Cache, true)
getAccessToken, err := token.GetAccessToken()
if err != nil {
t.Errorf("got a error: %s", err.Error())
return
}
t.Logf("got a value: %s", getAccessToken)
}
// 基准测试看获取token的次数?
func BenchmarkDouyinOpenapi_NewDefaultAccessToken(b *testing.B) {
token := accessToken.NewDefaultAccessToken(AppId, AppSecret, Cache, true)
for i := 0; i < b.N; i++ {
getAccessToken, err := token.GetAccessToken()
b.Logf("get token: %s %+v", getAccessToken, err)
}
}
// 测试小程序登录
func TestDouYinOpenApi_Code2Session(t *testing.T) {
session, err := OpenApi.Code2Session("1111", "")
if err != nil {
t.Errorf("got a error %s", err.Error())
return
}
t.Logf("got a value %+v", session)
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module gitea.youtukeji.com.cn/xiabin/douyin-openapi
go 1.23.4

27
util/http.go Normal file
View File

@ -0,0 +1,27 @@
package util
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
// PostJSON post json 数据请求
func PostJSON(uri string, obj any) ([]byte, error) {
marshal, err := json.Marshal(obj)
if err != nil {
return nil, err
}
response, err := http.Post(uri, "application/json;charset=utf-8", bytes.NewBuffer(marshal))
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http get error : uri=%v , statusCode=%v", uri, response.StatusCode)
}
return io.ReadAll(response.Body)
}