完成游戏人生账号绑定、解绑、绑定信息的接口开发

This commit is contained in:
2025-06-09 16:27:18 +08:00
parent 5ead851b99
commit fee4d55725
27 changed files with 764 additions and 25 deletions

58
utility/encrypt/aes.go Normal file
View File

@ -0,0 +1,58 @@
package encrypt
import (
"crypto/aes"
"crypto/cipher"
"errors"
)
// AesEncrypt 使用 AES-CBC 模式对数据进行加密。
//
// 参数:
// - plainText: 原始明文数据(必须是任意长度)
// - key: 加密密钥(长度必须是 16、24 或 32 字节)
// - iv: 初始化向量(必须是 16 字节)
//
// 返回值:
// - 加密后的密文
// - 错误信息(如果加密失败)
func AesEncrypt(plainText, key, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != aes.BlockSize {
return nil, errors.New("IV 长度必须为 16 字节")
}
plainText = pkcs7Padding(plainText, aes.BlockSize)
cipherText := make([]byte, len(plainText))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(cipherText, plainText)
return cipherText, nil
}
// pkcs7Padding 对数据进行 PKCS7 填充。
//
// 参数:
// - data: 原始数据
// - blockSize: 块大小(通常为 16
//
// 返回值:
// - 填充后的数据
func pkcs7Padding(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padText := bytesRepeat(byte(padding), padding)
return append(data, padText...)
}
// bytesRepeat 返回一个重复 count 次的字节切片。
func bytesRepeat(b byte, count int) []byte {
buf := make([]byte, count)
for i := 0; i < count; i++ {
buf[i] = b
}
return buf
}

19
utility/encrypt/bs4.go Normal file
View File

@ -0,0 +1,19 @@
package encrypt
import (
"encoding/base64"
)
// Base64Encode 对字符串进行 base64 编码
func Base64Encode(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
// Base64Decode 对 base64 编码的字符串进行解码
func Base64Decode(encoded string) ([]byte, error) {
decodedBytes, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
return decodedBytes, nil
}

View File

@ -1,4 +1,4 @@
package utility
package encrypt
import (
"golang.org/x/crypto/bcrypt"

View File

@ -1,4 +1,4 @@
package utility
package encrypt
import (
"golang.org/x/crypto/bcrypt"

View File

@ -0,0 +1,287 @@
package gamelife
import (
"context"
"encoding/json"
"fmt"
"github.com/go-resty/resty/v2"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/util/gconv"
"github.com/gogf/gf/v2/util/grand"
"net/url"
"server/internal/consts"
"server/internal/model"
"server/utility/ecode"
"server/utility/encrypt"
"server/utility/rsa"
"time"
)
type gamelifeClient struct {
PlatId string `json:"platId" `
Mode string `json:"mode" `
keyivUrlMap map[string]string `json:"-"` // 存储获取用户 aes key 和 iv 的 url
boundUrlMap map[string]string `json:"-"` // 存储用户绑定状态的 url
unBoundUrlMap map[string]string `json:"-"` // 存储用户解绑状态的 url
getBoundUrl map[string]string `json:"-"` // 存储用户绑定状态的 url
}
var (
instance *gamelifeClient
)
func newgamelifeClient(ctx context.Context) *gamelifeClient {
instance = &gamelifeClient{
PlatId: g.Config().MustGet(ctx, "gamelife.platId").String(),
Mode: g.Config().MustGet(ctx, "gamelife.mode").String(),
keyivUrlMap: map[string]string{
"test": "https://api-test.nes.smoba.qq.com/pvpesport.sgamenes.commcgi.commcgi/GetExplatSecret",
"prod": "https://api.nes.smoba.qq.com/pvpesport.sgamenes.commcgi.commcgi/GetExplatSecret",
},
boundUrlMap: map[string]string{
"test": "https://h5-test.cafe.qq.com/pmd-mobile.cafe.bind-account.pc.feature.bind-account/#/index",
"prod": "https://h5.cafe.qq.com/pmd-mobile.cafe.bind-account.pc/#/index",
},
unBoundUrlMap: map[string]string{
"test": "https://h5-test.cafe.qq.com/pmd-mobile.cafe.bind-account.pc.feature.bind-account/#/bind-manage",
"prod": "https://h5.cafe.qq.com/pmd-mobile.cafe.bind-account.pc/#/bind-manage",
},
getBoundUrl: map[string]string{
"test": "https://api-test。cafe.qq.com/tipmp.user.authinfoo_cgi.authinfo_cgi/GetPlatUserInfo",
"prod": "https://api.cafe.qq.com/tipmp.user.authinfoo_cgi.authinfo_cgi/GetPlatUserInfo",
},
}
glog.Infof(ctx, "初始化 gamelifeClient 成功")
return instance
}
func init() {
ctx := context.Background()
newgamelifeClient(ctx)
}
func GetGamelifeClient(ctx context.Context) *gamelifeClient {
if instance == nil {
instance = newgamelifeClient(ctx)
}
return instance
}
func (s *gamelifeClient) GetUserKeyIV(ctx context.Context, popenId string) (cache model.UserGamelifeCache, err error) {
// 创建加密原数据
oriData := g.MapStrStr{
"PlatId": s.PlatId,
"PopenId": popenId,
}
marshal, err := json.Marshal(oriData)
if err != nil {
err = ecode.Fail.Sub("序列化 json 数据出现异常")
return
}
key, err := rsa.GetRsaClient().EncryptWithRsaPublicKey(marshal)
if err != nil {
err = ecode.Fail.Sub("序列化 json 数据出现异常")
return
}
// base64编码
base64Encode := encrypt.Base64Encode(key)
// 向游戏人生发送请求
result := struct {
secret string `json:"secret"`
key string `json:"key"` // 该用户的token后续的账号绑定、登录态携带都需要此参数。
}{}
resp, err := resty.New().R().SetBody(map[string]string{
"plat_id": s.PlatId,
"key": base64Encode,
}).SetResult(result).Post(s.keyivUrlMap[s.Mode])
if err != nil {
err = ecode.Fail.Sub("获取用户信息失败")
return
}
if resp.StatusCode() != 200 {
err = ecode.Fail.Sub("获取用户信息失败")
return
}
decode, err := encrypt.Base64Decode(result.secret)
if err != nil {
err = ecode.Fail.Sub("解密用户信息失败")
return
}
plain, err := rsa.GetRsaClient().DecryptWithRsaPrivateKey(decode)
if err != nil {
err = ecode.Fail.Sub("解密用户信息失败")
return
}
aesResult := struct {
key string `json:"key"`
iv string `json:"iv"`
}{}
if err = json.Unmarshal(plain, &aesResult); err != nil {
err = ecode.Fail.Sub("解密用户信息失败")
return
}
gamelifeCache := model.UserGamelifeCache{Aes: aesResult.key, IV: aesResult.iv, Token: result.key}
// 将用户的 aeskey 和 iv 存储到缓存当中,用于后续请求数据加密, 固定时间 2 小时同时不同用户加上一个随机时间
if err = g.Redis().SetEX(ctx, fmt.Sprintf(consts.GameLifeUserKey, popenId), gamelifeCache, int64(consts.GameLifeUserExpire+grand.Intn(1000))); err != nil {
err = ecode.Fail.Sub("设置用户信息失败")
return
}
return
}
// GetUrl 为 GameLife 系统生成绑定或解绑定的 URL。
// 它从缓存中获取用户加密密钥(若缓存过期则重新获取),加密用户数据,
// 并根据操作类型(绑定或解绑)构造包含查询参数的 URL。
//
// 参数:
// - ctx: 用于控制请求生命周期和取消的上下文。
// - popenid: 用户的唯一标识符。
// - appname: 应用程序名称。
// - nickname: 用户昵称,仅在解绑操作时需要(绑定操作时忽略)。
// - bindType: 绑定类型1 表示 QQ其他值表示微信
// - isBound: 布尔值指示操作类型true 表示绑定false 表示解绑)。
//
// 返回值:
// - string: 构造的绑定或解绑操作的 URL。
// - error: 如果任何步骤缓存获取、密钥获取、JSON 序列化、加密或 URL 构造)失败,则返回错误。
//
// 错误:
// - 如果发生以下情况,将返回带有具体错误信息的错误:
// - 无法解析基础 URL。
// - 无法从缓存或通过 GetUserKeyIV 获取用户信息。
// - JSON 序列化或反序列化失败。
// - AES 加密或 Base64 编码失败。
func (s *gamelifeClient) GetUrl(ctx context.Context, popenid, appname, nickname string, bindType int, isBound bool) (string, error) {
// 从缓存中获取用户的 aes 和 iv
rooturl := s.boundUrlMap[s.Mode]
if !isBound {
rooturl = s.unBoundUrlMap[s.Mode]
}
baseUrl, err := url.Parse(rooturl)
if err != nil {
return "", ecode.Fail.Sub("解析基础 URL 失败")
}
cacheData, err := g.Redis().Get(ctx, fmt.Sprintf(consts.GameLifeUserKey, popenid))
if err != nil {
return "", ecode.Fail.Sub("从缓存中获取用户信息失败")
}
var gamelifeCache model.UserGamelifeCache
if cacheData.IsEmpty() {
// 如果缓存不存在或已过期,重新调用 GetUserKeyIV
data, err := s.GetUserKeyIV(ctx, popenid)
if err != nil {
return "", ecode.Fail.Sub("获取用户信息失败")
}
gamelifeCache = model.UserGamelifeCache{Aes: data.Aes, IV: data.IV, Token: data.Token}
} else {
// 缓存存在,直接解析
if err = json.Unmarshal(cacheData.Bytes(), &gamelifeCache); err != nil {
return "", ecode.Fail.Sub("解析用户信息失败")
}
}
// 序列化原始数据
oriData := g.Map{
"PopenId": popenid,
"TimeStamp": time.Now().Unix(),
}
marshal, err := json.Marshal(oriData)
if err != nil {
return "", ecode.Fail.Sub("序列化用户信息失败")
}
// 加密用户信息
aesEncrypt, err := encrypt.AesEncrypt(marshal, []byte(gamelifeCache.Aes), []byte(gamelifeCache.IV))
if err != nil {
return "", ecode.Fail.Sub("加密用户信息失败")
}
platUserInfoStr := encrypt.Base64Encode(aesEncrypt)
explatData := g.MapStrStr{"Token": gamelifeCache.Token, "PlatUserInfoStr": platUserInfoStr}
// 构建查询参数
queryParams := baseUrl.Query()
queryParams.Add("app_name", appname)
queryParams.Add("mini_program_band", consts.GamelifeMiniProgramBand)
queryParams.Add("extplat_plat", s.PlatId)
queryParams.Add("extplat_type", consts.GamelifeExtplatType)
queryParams.Add("extplat_extra", consts.GamelifeExtplatExtraPc)
queryParams.Add("extplat_data", url.QueryEscape(gconv.String(explatData)))
// 根据 isBound 设置 bind_type 和 nickname
if bindType == 1 {
queryParams.Add("bind_type", consts.GamelifeExtplatBoundTypeQQ)
} else {
queryParams.Add("bind_type", consts.GamelifeExtplatBoundTypeWX)
}
// 仅在解绑时添加 nickname
if !isBound {
queryParams.Add("nickname", nickname)
}
baseUrl.RawQuery = queryParams.Encode()
return baseUrl.String(), nil
}
// GetBound 获取用户绑定情况
func (s *gamelifeClient) GetBound(ctx context.Context, popenid string) (string, error) {
baseUrl, err := url.Parse(s.getBoundUrl[s.Mode])
if err != nil {
return "", ecode.Fail.Sub("解析基础 URL 失败")
}
cacheData, err := g.Redis().Get(ctx, fmt.Sprintf(consts.GameLifeUserKey, popenid))
if err != nil {
return "", ecode.Fail.Sub("从缓存中获取用户信息失败")
}
var gamelifeCache model.UserGamelifeCache
if cacheData.IsEmpty() {
// 如果缓存不存在或已过期,重新调用 GetUserKeyIV
data, err := s.GetUserKeyIV(ctx, popenid)
if err != nil {
return "", ecode.Fail.Sub("获取用户信息失败")
}
gamelifeCache = model.UserGamelifeCache{Aes: data.Aes, IV: data.IV, Token: data.Token}
} else {
// 缓存存在,直接解析
if err = json.Unmarshal(cacheData.Bytes(), &gamelifeCache); err != nil {
return "", ecode.Fail.Sub("解析用户信息失败")
}
}
// 序列化原始数据
oriData := g.Map{
"PopenId": popenid,
"TimeStamp": time.Now().Unix(),
}
marshal, err := json.Marshal(oriData)
if err != nil {
return "", ecode.Fail.Sub("序列化用户信息失败")
}
// 加密用户信息
aesEncrypt, err := encrypt.AesEncrypt(marshal, []byte(gamelifeCache.Aes), []byte(gamelifeCache.IV))
if err != nil {
return "", ecode.Fail.Sub("加密用户信息失败")
}
platUserInfoStr := encrypt.Base64Encode(aesEncrypt)
explatData := g.MapStrStr{"Token": gamelifeCache.Token, "PlatUserInfoStr": platUserInfoStr}
queryParams := baseUrl.Query()
queryParams.Add("plat_id", s.PlatId)
queryParams.Add("plat_user_info", popenid)
queryParams.Add("plat_user_str", url.QueryEscape(gconv.String(explatData)))
baseUrl.RawQuery = queryParams.Encode()
return baseUrl.String(), nil
}

126
utility/rsa/rsa.go Normal file
View File

@ -0,0 +1,126 @@
package rsa
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"os"
"sync"
"github.com/gogf/gf/v2/frame/g"
)
type rsaClient struct {
publicKey *rsa.PublicKey
privateKey *rsa.PrivateKey
}
var (
instance *rsaClient
once sync.Once
)
// init 会在包初始化时自动调用,用于加载默认的 RSA 公钥和私钥。
func init() {
ctx := context.Background()
once.Do(func() {
instance = &rsaClient{}
err := instance.loadKeys(g.Config().MustGet(ctx, "rsa.publickey").String(), g.Config().MustGet(ctx, "rsa.privatekey").String())
if err != nil {
panic("加载 RSA 密钥失败: " + err.Error())
}
})
}
// GetRsaClient 返回 RSA 客户端的单例实例。
//
// 通常用于执行加解密操作。
func GetRsaClient() *rsaClient {
return instance
}
// EncryptWithRsaPublicKey 使用加载的 RSA 公钥对原始数据进行加密。
//
// 参数:
// - plain: 待加密的明文数据。
//
// 返回值:
// - 加密后的密文数据。
// - 如果加密失败,则返回错误。
func (c *rsaClient) EncryptWithRsaPublicKey(plain []byte) ([]byte, error) {
if c.publicKey == nil {
return nil, errors.New("公钥未加载")
}
return rsa.EncryptPKCS1v15(rand.Reader, c.publicKey, plain)
}
// DecryptWithRsaPrivateKey 使用加载的 RSA 私钥对密文数据进行解密。
//
// 参数:
// - cipher: 加密后的密文数据。
//
// 返回值:
// - 解密后的明文数据。
// - 如果解密失败,则返回错误。
func (c *rsaClient) DecryptWithRsaPrivateKey(cipher []byte) ([]byte, error) {
if c.privateKey == nil {
return nil, errors.New("私钥未加载")
}
return rsa.DecryptPKCS1v15(rand.Reader, c.privateKey, cipher)
}
// loadKeys 从指定文件中加载 RSA 公钥和私钥。
//
// 参数:
// - publicKeyPath: 公钥 PEM 文件路径。
// - privateKeyPath: 私钥 PEM 文件路径。
//
// 返回值:
// - 成功返回 nil否则返回错误信息。
func (c *rsaClient) loadKeys(publicKeyPath, privateKeyPath string) error {
// 加载公钥
pubBytes, err := os.ReadFile(publicKeyPath)
if err != nil {
return err
}
pubBlock, _ := pem.Decode(pubBytes)
if pubBlock == nil {
return errors.New("无法解析公钥 PEM 文件")
}
pubKey, err := x509.ParsePKIXPublicKey(pubBlock.Bytes)
if err != nil {
return err
}
var ok bool
if c.publicKey, ok = pubKey.(*rsa.PublicKey); !ok {
return errors.New("公钥不是 RSA 公钥")
}
// 加载私钥
privBytes, err := os.ReadFile(privateKeyPath)
if err != nil {
return err
}
privBlock, _ := pem.Decode(privBytes)
if privBlock == nil {
return errors.New("无法解析私钥 PEM 文件")
}
// 尝试解析 PKCS#8 格式
privKey, err := x509.ParsePKCS8PrivateKey(privBlock.Bytes)
if err != nil {
// 回退尝试 PKCS#1 格式
privKey, err = x509.ParsePKCS1PrivateKey(privBlock.Bytes)
if err != nil {
return errors.New("解析私钥失败: 既不是 PKCS#8 也不是 PKCS#1 格式")
}
}
var ok2 bool
if c.privateKey, ok2 = privKey.(*rsa.PrivateKey); !ok2 {
return errors.New("私钥不是 RSA 私钥")
}
return nil
}