475 lines
14 KiB
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,
|
|
Deleted: 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,
|
|
Deleted: 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
|
|
}
|