wz-golang-server/server/modules/api/service/wechat_service.go

475 lines
14 KiB
Go

// Package service 业务逻辑层
package service
import (
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"server/common"
"server/config"
apiDto "server/modules/api/dto"
systemEntity "server/modules/system/entity"
userEntity "server/modules/user/entity"
userMapper "server/modules/user/mapper"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
wechatMiniProgramType int8 = 1
)
type WechatMiniProgramService struct {
userMapper *userMapper.UserMapper
platformUserMapper *userMapper.PlatformUserMapper
httpClient *http.Client
}
func NewWechatMiniProgramService() *WechatMiniProgramService {
return &WechatMiniProgramService{
userMapper: userMapper.NewUserMapper(),
platformUserMapper: userMapper.NewPlatformUserMapper(),
httpClient: &http.Client{
Timeout: 5 * time.Second,
},
}
}
// Login 微信小程序登录
func (s *WechatMiniProgramService) Login(req *apiDto.WechatMiniLoginRequest) (*apiDto.WechatMiniLoginResponse, error) {
cfg := config.AppConfig.Wechat.MiniProgram
if strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.AppSecret) == "" {
return nil, errors.New("微信小程序配置缺失")
}
session, err := s.exchangeCodeForSession(req.Code, cfg.AppID, cfg.AppSecret)
if err != nil {
return nil, err
}
now := time.Now()
isNewPlatform := false
isNewUser := false
var userID int64
var platformUserID int64
var phone string
if req.PhoneCode != "" {
phoneResp, err := s.getPhoneNumberByCode(req.PhoneCode, cfg.AppID, cfg.AppSecret)
if err != nil {
return nil, err
}
phone = phoneResp.PhoneInfo.PhoneNumber
} else if req.EncryptedData != "" && req.IV != "" {
phoneResp, err := decryptPhoneNumber(req.EncryptedData, req.IV, session.SessionKey)
if err != nil {
return nil, err
}
phone = phoneResp.PhoneNumber
} else {
phone = normalizeString(req.Phone)
}
phonePtr := normalizeOptionalString(&phone)
platformUser, err := s.platformUserMapper.FindByPlatformOpenID(wechatMiniProgramType, session.OpenID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
isNewPlatform = true
isNewUser = true
nickname := req.Nickname
if phone != "" {
nickname = maskPhone(phone)
}
user := &userEntity.User{
Username: generateWechatUsername(),
Nickname: nickname,
AvatarURL: chooseAvatarURL(req.AvatarURL),
Phone: phonePtr,
Gender: 0,
Status: 1,
DelFlag: 0,
}
if phone != "" {
salt := uuid.NewString()[:8]
encrypted, err := common.Encrypt(phone, "123456", salt)
if err != nil {
return nil, err
}
user.Password = &encrypted
user.Salt = &salt
}
if req.Gender != nil {
user.Gender = *req.Gender
}
if err := s.userMapper.Create(user); err != nil {
return nil, err
}
platform := &userEntity.PlatformUser{
UserID: user.ID,
PlatformType: wechatMiniProgramType,
PlatformOpenID: session.OpenID,
PlatformUnionID: session.UnionID,
PlatformSessionKey: session.SessionKey,
PlatformExtra: req.PlatformExtra,
LastLoginTime: &now,
DelFlag: 0,
}
if err := s.platformUserMapper.Create(platform); err != nil {
return nil, err
}
userID = user.ID
platformUserID = platform.ID
} else {
return nil, err
}
} else {
userID = platformUser.UserID
platformUserID = platformUser.ID
fields := map[string]interface{}{
"platform_session_key": session.SessionKey,
"last_login_time": now,
}
if session.UnionID != "" {
fields["platform_unionid"] = session.UnionID
}
if req.PlatformExtra != nil {
fields["platform_extra"] = req.PlatformExtra
}
if err := s.platformUserMapper.UpdateFields(platformUser.ID, fields); err != nil {
return nil, err
}
userFields := map[string]interface{}{}
if req.Nickname != "" {
userFields["nickname"] = req.Nickname
}
if req.AvatarURL != "" {
userFields["avatar_url"] = req.AvatarURL
}
if req.Gender != nil {
userFields["gender"] = *req.Gender
}
if phonePtr != nil {
userFields["phone"] = phonePtr
}
if len(userFields) > 0 {
userFields["update_time"] = now
_ = s.userMapper.UpdateFields(userID, userFields)
}
}
token, err := s.saveLoginUser(userID, req, session, phone)
if err != nil {
return nil, err
}
return &apiDto.WechatMiniLoginResponse{
UserID: userID,
PlatformUserID: platformUserID,
OpenID: session.OpenID,
UnionID: session.UnionID,
SessionKey: session.SessionKey,
Phone: phone,
Token: token,
IsNewPlatform: isNewPlatform,
IsNewUser: isNewUser,
}, nil
}
type wechatSessionResponse struct {
OpenID string `json:"openid"`
SessionKey string `json:"session_key"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
type wechatAccessTokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
type wechatPhoneNumberResponse struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
PhoneInfo struct {
PhoneNumber string `json:"phoneNumber"`
PurePhoneNumber string `json:"purePhoneNumber"`
CountryCode string `json:"countryCode"`
} `json:"phone_info"`
}
func (s *WechatMiniProgramService) exchangeCodeForSession(code, appID, appSecret string) (*wechatSessionResponse, error) {
query := url.Values{}
query.Set("appid", appID)
query.Set("secret", appSecret)
query.Set("js_code", code)
query.Set("grant_type", "authorization_code")
endpoint := fmt.Sprintf("https://api.weixin.qq.com/sns/jscode2session?%s", query.Encode())
resp, err := s.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("请求微信接口失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("微信接口返回异常状态: %d", resp.StatusCode)
}
var data wechatSessionResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("解析微信接口响应失败: %w", err)
}
if data.ErrCode != 0 {
return nil, fmt.Errorf("微信接口错误: %d %s", data.ErrCode, data.ErrMsg)
}
if data.OpenID == "" || data.SessionKey == "" {
return nil, errors.New("微信接口返回数据不完整")
}
return &data, nil
}
func normalizeOptionalString(val *string) *string {
if val == nil {
return nil
}
trimmed := strings.TrimSpace(*val)
if trimmed == "" {
return nil
}
return &trimmed
}
func normalizeString(val *string) string {
if val == nil {
return ""
}
return strings.TrimSpace(*val)
}
func generateWechatUsername() string {
return "wx_" + time.Now().Format("20060102150405") + "_" + uuid.NewString()[:8]
}
var defaultAvatarURLs = []string{
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/xianxingnvxuesheng.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/xianxingnanxuesheng.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/nvxuesheng.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/nvxuesheng_1.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/nanxuesheng.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/nanxuesheng_1.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/a-389363773.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/a-389363772.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/a-389363771.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/a-389363770.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/a-389363769.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/a-389363768.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/a-389363767.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/a-8nvxuesheng.png?imageSlim",
"https://oss-1322049369.cos.ap-beijing.myqcloud.com/images/a-8nanxuesheng.png?imageSlim",
}
func chooseAvatarURL(input string) string {
if strings.TrimSpace(input) != "" {
return input
}
return defaultAvatarURLs[time.Now().UnixNano()%int64(len(defaultAvatarURLs))]
}
func maskPhone(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < 7 {
return phone
}
return phone[:3] + "****" + phone[len(phone)-4:]
}
func (s *WechatMiniProgramService) getAccessToken(appID, appSecret string) (string, error) {
ctx := context.Background()
cacheKey := "wechat:access_token"
if token, err := config.RDB.Get(ctx, cacheKey).Result(); err == nil && strings.TrimSpace(token) != "" {
return token, nil
}
query := url.Values{}
query.Set("grant_type", "client_credential")
query.Set("appid", appID)
query.Set("secret", appSecret)
endpoint := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/token?%s", query.Encode())
resp, err := s.httpClient.Get(endpoint)
if err != nil {
return "", fmt.Errorf("请求微信 access_token 失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("微信 access_token 返回异常状态: %d", resp.StatusCode)
}
var data wechatAccessTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return "", fmt.Errorf("解析微信 access_token 响应失败: %w", err)
}
if data.ErrCode != 0 {
return "", fmt.Errorf("微信 access_token 错误: %d %s", data.ErrCode, data.ErrMsg)
}
if strings.TrimSpace(data.AccessToken) == "" {
return "", errors.New("微信 access_token 返回为空")
}
expire := time.Duration(data.ExpiresIn) * time.Second
if expire <= 0 {
expire = time.Hour
}
_ = config.RDB.Set(ctx, cacheKey, data.AccessToken, expire-time.Minute).Err()
return data.AccessToken, nil
}
func (s *WechatMiniProgramService) getPhoneNumberByCode(phoneCode, appID, appSecret string) (*wechatPhoneNumberResponse, error) {
token, err := s.getAccessToken(appID, appSecret)
if err != nil {
return nil, err
}
endpoint := fmt.Sprintf("https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=%s", url.QueryEscape(token))
payload := map[string]string{
"code": phoneCode,
}
body, _ := json.Marshal(payload)
resp, err := s.httpClient.Post(endpoint, "application/json", strings.NewReader(string(body)))
if err != nil {
return nil, fmt.Errorf("请求微信手机号接口失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("微信手机号接口返回异常状态: %d", resp.StatusCode)
}
var data wechatPhoneNumberResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("解析微信手机号接口响应失败: %w", err)
}
if data.ErrCode != 0 {
return nil, fmt.Errorf("微信手机号接口错误: %d %s", data.ErrCode, data.ErrMsg)
}
if data.PhoneInfo.PhoneNumber == "" {
return nil, errors.New("微信手机号接口返回为空")
}
return &data, nil
}
type wechatPhoneDecrypted struct {
PhoneNumber string `json:"phoneNumber"`
PurePhoneNumber string `json:"purePhoneNumber"`
CountryCode string `json:"countryCode"`
Watermark struct {
AppID string `json:"appid"`
Timestamp int64 `json:"timestamp"`
} `json:"watermark"`
}
func decryptPhoneNumber(encryptedData, iv, sessionKey string) (*wechatPhoneDecrypted, error) {
cipherText, err := base64.StdEncoding.DecodeString(encryptedData)
if err != nil {
return nil, fmt.Errorf("encryptedData 解码失败: %w", err)
}
ivBytes, err := base64.StdEncoding.DecodeString(iv)
if err != nil {
return nil, fmt.Errorf("iv 解码失败: %w", err)
}
keyBytes, err := base64.StdEncoding.DecodeString(sessionKey)
if err != nil {
return nil, fmt.Errorf("session_key 解码失败: %w", err)
}
if len(keyBytes) != 16 {
return nil, errors.New("session_key 长度不正确")
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return nil, fmt.Errorf("创建 AES cipher 失败: %w", err)
}
if len(cipherText)%aes.BlockSize != 0 {
return nil, errors.New("密文长度不正确")
}
mode := cipher.NewCBCDecrypter(block, ivBytes)
plainText := make([]byte, len(cipherText))
mode.CryptBlocks(plainText, cipherText)
plainText, err = pkcs7Unpad(plainText)
if err != nil {
return nil, err
}
var data wechatPhoneDecrypted
if err := json.Unmarshal(plainText, &data); err != nil {
return nil, fmt.Errorf("手机号解密结果解析失败: %w", err)
}
if data.PhoneNumber == "" {
return nil, errors.New("手机号解密结果为空")
}
return &data, nil
}
func pkcs7Unpad(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("PKCS7 数据为空")
}
padding := int(data[len(data)-1])
if padding == 0 || padding > len(data) {
return nil, errors.New("PKCS7 padding 不合法")
}
for i := 0; i < padding; i++ {
if data[len(data)-1-i] != byte(padding) {
return nil, errors.New("PKCS7 padding 校验失败")
}
}
return data[:len(data)-padding], nil
}
func (s *WechatMiniProgramService) saveLoginUser(userID int64, req *apiDto.WechatMiniLoginRequest, session *wechatSessionResponse, phone string) (string, error) {
token := uuid.NewString()
loginUser := &systemEntity.LoginUser{
ID: strconv.FormatInt(userID, 10),
Username: chooseUsername(phone, session.OpenID),
Realname: req.Nickname,
Avatar: req.AvatarURL,
Phone: phone,
Email: "",
Token: token,
}
data, err := json.Marshal(loginUser)
if err != nil {
return "", err
}
if err := config.RDB.Set(context.Background(), common.RedisTokenPrefix+token, data, common.RedisTokenExpire).Err(); err != nil {
return "", err
}
return token, nil
}
func chooseUsername(phone, openid string) string {
if strings.TrimSpace(phone) != "" {
return phone
}
return openid
}