// 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 }