每日一Go-42、Go语言微信小程序登录是如何实现的?

13 阅读9分钟

    大部分微信小程序都有一个基本的登录需求,最好的方式当然是利用微信登录让用户进入得更丝滑和顺畅。话不多说,直接开干,前端我们选用uni-app来做UI,后端用我的工具comer(github.com/imoowi/come…

图片

一、后端接口

1.1 小程序登录接口

1.1.1 初始化全局MiniProgram对象,直接用开源的库

图片

//internal/global/wechat.go
package global
import (
    "github.com/silenceper/wechat/v2"
    miniCache "github.com/silenceper/wechat/v2/cache"
    "github.com/silenceper/wechat/v2/miniprogram"
    miniConfig "github.com/silenceper/wechat/v2/miniprogram/config"
    "github.com/spf13/viper"
)
var Mini *miniprogram.MiniProgram
func initWechat() {
    config := viper.Sub("wechat.miniprogram")
    wc := wechat.NewWechat()
    cfg := &miniConfig.Config{
        AppID:     config.GetString("AppID"),
        AppSecret: config.GetString("AppSecret"),
        Cache:     miniCache.NewMemory(),
    }
    Mini = wc.GetMiniProgram(cfg)
}

1.1.2 定义model

//internal/models/wechat_user.model.go
/*
generated by comer,https://github.com/imoowi/comer
Copyright © 2023 jun<simpleyuan@gmail.com>
*/
package models
import (
    "github.com/imoowi/comer/components"
    "github.com/spf13/cast"
    "gorm.io/gorm"
)
type WechatUserBase struct {
    NickName             string `json:"nickname" form:"nickname" gorm:"column:nickname;type:varchar(30);not null;comment:昵称" `
    OpenID               string `json:"openid" form:"openid" gorm:"column:openid;type:varchar(50);not null;comment:openid;uniqueIndex" `
    UnionID              string `json:"unionid" form:"unionid" gorm:"column:unionid;type:varchar(50);not null;comment:unionid" `
    Avatar               string `json:"avatar" form:"avatar" gorm:"column:avatar;type:varchar(255);not null;comment:头像" `
}
// WechatUser表
type WechatUser struct {
    components.GormModel
    WechatUserBase
}
// IModel.GetID实现
func (m *WechatUser) GetID() uint {
    return m.ID
}
func (m *WechatUser) SetId(id uint) {
    m.ID = id
}
// IModel.TableName实现
func (m *WechatUser) TableName() string {
    return `wechat_user` + `s`
}
func (m *WechatUser) AfterUpdate(tx *gorm.DB) error {
    ClearWechatUserCache(m.ID)
    return nil
}
func (m *WechatUser) AfterDelete(tx *gorm.DB) error {
    ClearWechatUserCache(m.ID)
    return nil
}
var userCache *components.MemCacheT[*WechatUser]
func init() {
    userCache = components.NewMemCacheT[*WechatUser]()
}
func GetWechatUserById(id uint, tx *gorm.DB) (*WechatUser, error) {
    key := "wechat_user_" + cast.ToString(id)
    data, ok := userCache.GetOne(key)
    if ok {
        return data, nil
    }
    var user *WechatUser
    err := tx.Where("id = ?", id).First(&user).Error
    if err != nil {
        return nil, err
    }
    userCache.SetOne(key, user)
    return user, nil
}
func ClearWechatUserCache(id uint) {
    key := "wechat_user_" + cast.ToString(id)
    userCache.Del(key)
}

1.1.3 定义repos

//internal/repos/wechat.repo.go
/*
generated by comer,https://github.com/imoowi/comer
Copyright © 2023 jun<simpleyuan@gmail.com>
*/
package repos

import (
"golang_per_day_42/internal/global"
"golang_per_day_42/internal/models"

"github.com/gin-gonic/gin"
"github.com/imoowi/comer/interfaces/impl"
)

var WechatUser *WechatUserRepo

type WechatUserRepo struct {
	impl.Repo[*models.WechatUser]
}

func NewWechatUserRepo() {
	db := global.MysqlDb.Client
	WechatUser = &WechatUserRepo{
		Repo: *impl.NewRepo[*models.WechatUser](db),
	}
}

func init() {
	RegisterRepos(NewWechatUserRepo)
}

func (r *WechatUserRepo) OneByOpenID(c *gin.Context, openID string, unionID string) (*models.WechatUser, error) {
var wechatUser models.WechatUser
var err error
if unionID != "" {
		err = global.MysqlDb.Client.Where("unionid = ?", unionID).First(&wechatUser).Error
	} else {
		err = global.MysqlDb.Client.Where("openid = ?", openID).First(&wechatUser).Error
if err != nil {
return nil, err
		}
	}
if err != nil {
return nil, err
	}
return &wechatUser, nil
}

1.1.4 定义service 

//internal/services/wechat.service.go
/*
generated by comer,https://github.com/imoowi/comer
Copyright © 2023 jun<simpleyuan@gmail.com>
*/
package services

import (
"golang_per_day_42/internal/models"
"golang_per_day_42/internal/repos"

"github.com/gin-gonic/gin"
"github.com/imoowi/comer/interfaces/impl"
)

var WechatUser *WechatUserService

type WechatUserService struct {
	impl.Service[*models.WechatUser]
}

func NewWechatUserService(r *repos.WechatUserRepo) *WechatUserService {
return &WechatUserService{
		Service: *impl.NewService[*models.WechatUser](r),
	}
}

func init() {
	RegisterServices(func() {
		WechatUser = NewWechatUserService(repos.WechatUser)
	})
}
func (s *WechatUserService) OneByOpenID(c *gin.Context, openID string, unionID string) (*models.WechatUser, error) {
return repos.WechatUser.OneByOpenID(c, openID, unionID)
}

1.1.5 登录接口

//internal/controllers/wechat.controller.go
/*
generated by comer,https://github.com/imoowi/comer
Copyright © 2023 jun<simpleyuan@gmail.com>
*/
package controllers

import (
"golang_per_day_42/internal/global"
"golang_per_day_42/internal/middlewares/token"
"golang_per_day_42/internal/models"
"golang_per_day_42/internal/services"
"errors"
"log"
"net/http"

"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/imoowi/comer/interfaces"
"github.com/imoowi/comer/utils/response"
"github.com/spf13/cast"
"gorm.io/gorm"
)

func WechatMiniLogin(c *gin.Context) {
var params struct {
		Code          string `json:"code"`
		EncryptedData string `json:"encryptedData"`
		Iv            string `json:"iv"`
	}
if err := c.BindJSON(&params); err != nil {
		response.Error(err.Error(), http.StatusBadRequest, c)
return
	}
	res, err := global.Mini.GetAuth().Code2Session(params.Code)
if err != nil {
		response.Error(err.Error(), http.StatusBadRequest, c)
return
	}
if res.ErrCode != 0 {
		response.Error(res.ErrMsg, http.StatusBadRequest, c)
return
	}

	wechatUser := &models.WechatUser{}
	wechatUser.OpenID = res.OpenID
	wechatUser.UnionID = res.UnionID

	user, err := services.WechatUser.OneByOpenID(c, res.OpenID, res.UnionID)
if user != nil && user.GetID() > 0 {
//本来准备做用户信息获取和更新,发现微信改了规则,只能由前端获取用户的昵称和头像了
	} else {
//新用户,存储到库里
		newId, err := services.WechatUser.Add(c, wechatUser)
if err != nil {
			response.Error(err.Error(), http.StatusBadRequest, c)
return
		}
		user = wechatUser
		user.SetId(newId)
	}
	_token, err := token.GenTokenWechat(user.GetID(), res.OpenID, res.SessionKey, res.UnionID)
if err != nil {
		response.Error(err.Error(), http.StatusBadRequest, c)
return
	}
	response.OK(gin.H{"token": _token, "user": user}, c)
}

1.2 保存用户基本信息接口(头像和昵称等)

//internal/controllers/wechat.controller.go
func WechatUpdate(c *gin.Context) {
	id := c.GetUint(`userId`)
	model := make(map[string]any)
	err := c.ShouldBindBodyWith(&model, binding.JSON)
if err != nil {
		response.Error(err.Error(), http.StatusBadRequest, c)
return
	}
	updated, err := services.WechatUser.Update(c, model, cast.ToUint(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
			response.Error(err.Error(), http.StatusNotFound, c)
return
		}
		response.Error(err.Error(), http.StatusBadRequest, c)
return
	}
	response.OK(updated, c)
}

1.3 添加路由

//internal/router/wechat.router.go
/*
generated by comer,https://github.com/imoowi/comer
Copyright © 2023 jun<simpleyuan@gmail.com>
*/
package router

import (
"golang_per_day_42/internal/controllers"
"golang_per_day_42/internal/middlewares"

"github.com/gin-gonic/gin"
)

func init() {
	RegisterRoute(WechatRouters)
}

func WechatRouters(e *gin.Engine) {
	api := e.Group("/api")
	wechats := api.Group("/wechat")
	{
		wechats.PUT("", middlewares.WechatAuthMiddleware(), controllers.WechatUpdate) //更新
		wechats.POST("/login", controllers.WechatMiniLogin)                //微信登录
	}
}

1.4 中间件

//internal/middlewares/WechatAuthMiddleware.go
/*
generated by comer,https://github.com/imoowi/comer
Copyright © 2023 jun<simpleyuan@gmail.com>
*/
package middlewares

import (
"net/http"
"strings"
"time"

	token "golang_per_day_42/internal/middlewares/token"

"github.com/imoowi/comer/utils/response"
"github.com/spf13/viper"

"github.com/gin-gonic/gin"
)

func WechatAuthMiddleware() func(c *gin.Context) {
return func(c *gin.Context) {
// //mock
// c.Set("userId", cast.ToUint(9))
// c.Next()
// return

// 客户端携带Token有三种方式 1.放在请求头 2.放在请求体 3.放在URI
// 这里假设Token放在Header的Authorization中,并使用Bearer开头
// 这里的具体实现方式要依据你的实际业务情况决定
		authHeader := c.Request.Header.Get("Authorization")
if authHeader == "" {
			response.Error("请求头中auth为空", http.StatusUnauthorized, c)
			c.Abort()
return
		}
// 按空格分割
		parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
			response.Error("请求头中auth为空", http.StatusUnauthorized, c)
			c.Abort()
return
		}

// parts[1]是获取到的tokenString,我们使用之前定义好的解析JWT的函数来解析它
		mc, err := token.ParseTokenWechat(parts[1])
if err != nil {
			response.Error("invaled Token", http.StatusUnauthorized, c)
			c.Abort()
return
		}
// 判断token是否即将过期
		refreshTokenTimeout := viper.GetDuration("settings.jwt.refresh_token_timeout")
		refreshTokenTimeoutSeconds := refreshTokenTimeout.Seconds()
		invalidTimeout := mc.StandardClaims.ExpiresAt - int64(refreshTokenTimeoutSeconds)
		timeNow := time.Now().Unix()
if invalidTimeout < int64(timeNow) {
//生成新token,放在header里,让前端接口能够捕获到,担心在某一次提交的时候,token过期;为了保证操作的延续性,做了一个临时的token续期功能
			tokenString, _ := token.GenTokenWechat(mc.UserId, mc.OpenID, mc.SessionKey, mc.UnionID)
			c.Header(`X-Auth-Token`, tokenString)
		}
//
// 将当前用户的id放在上下文里,后续的控制器、service、repos层都可以通过c.GetUint(`userId`)来取到当前登录的id
		c.Set("userId", mc.UserId)
		c.Next()
	}
}

二、前端UI,用uni-app做

图片

    uni-app是用vue做UI,我更喜欢setup语法糖,你呢?

2.1 添加api接口

//api/user.js
import request, { post ,put} from '../utils/request.js';

// 登录接口
export const login = (userData) => {
  return post('/api/wechat/login', userData, { loading: true }); // 此接口显示loading
};
// 更新接口
export const updateUserInfo = (data={})=>{
return put('/api/wechat',data,{loading:true})
}

2.2 拦截http请求,方便做http状态码校验和token续期

//utils/request.js
import config from '../config.js'
import store from '../store/index.js';
const defaultOptions = {
  timeout: 15000,
  dataType: 'json',
  header: {
    'content-type''application/json'
  }
};

export default function request(options = {}) {
  const { url, method = 'GET', data = {}, header = {}, loading = true } = options;
  // 根据环境变量拼接完整URL
  const fullUrl = config.api_host + url;

  // 合并请求头,自动添加 Token
  const mergedHeader = {
    'Authorization''Bearer '+ uni.getStorageSync('token') || '',
    ...defaultOptions.header,
    ...header
  };

  return new Promise((resolve, reject) => {
    // 显示 Loading
    if (loading) {
      uni.showLoading({ title: '加载中...', mask: true });
    }
    uni.request({
      url: fullUrl,
      method: method.toUpperCase(),
      data: data,
      header: mergedHeader,
      success: (res) => {
		  const responseHeaders = res.header; 
		              // 2. 尝试从响应头中读取 Gin 设置的自定义 Token
		              // 注意:HTTP Header 名称是大小写不敏感的,但 H5/小程序环境读取时可能被转为小写或首字母大写

		              let token = responseHeaders['X-Auth-Token'] || 
		                                      responseHeaders['x-auth-token'] || 
		                                      responseHeaders['X-AUTH-TOKEN'];

		              if (token) {
						  uni.setStorageSync('token', token)
					  }
        // 响应状态码处理
        if (res.statusCode === 200) {
          // 可根据业务代码进一步处理 (如 res.data.code)
          resolve(res.data);
        } else {
          //如果是401,则清除token,并告诉store需要给出登录提示了
          if(res.statusCode === 401){
            uni.removeStorageSync('token')
            uni.removeStorageSync('userInfo')
            store.dispatch('openLoginPopup')
          }
          reject(new Error(`网络请求失败,状态码:${res.statusCode}`));
        }
      },
      fail: (err) => {
        uni.showToast({ title: '网络连接失败', icon: 'none' });
        reject(err);
      },
      complete: () => {
        if (loading) {
          uni.hideLoading();
        }
      }
    });
  });
}

// 可选的快捷方法封装
export const get = (url, data = {}, options = {}) => {
  return request({ url, data, method: 'GET', ...options });
};
export const post = (url, data = {}, options = {}) => {
  return request({ url, data, method: 'POST', ...options });
};
export const put = (url, data = {}, options = {}) => {
  return request({ url, data, method: 'PUT', ...options });
};
export const patch = (url, data = {}, options = {}) => {
  return request({ url, data, method: 'PATCH', ...options });
};
export const del = (url, data = {}, options = {}) => {
  return request({ url, data, method: 'DELETE', ...options });
};

2.3 添加全局登录弹窗提示页面 components/GlobalLoginPopup.vue

<template>
<uni-popup ref="popupRef" type="center" :is-mask-click="false">
<view class="need-login">
<view class="login-container">
<view class="login-header">
<view class="title-wrapper">
<text class="subtitle">您的操作需要登录</text>
</view>
</view>

<!-- 登录按钮区域 -->
<view class="login-actions">
<button class="login-button" type="primary" @click="doLogin">
<view class="button-content">
<text class="wechat-symbol">Ξ</text> <!-- 简单的三横图标 -->
<text>微信一键登录</text>
</view>
</button>
<button type="default" @click="closePopup()">
<view>
<uni-icons type="closeempty" size="20" color="#b0acb4"></uni-icons>
</view>
</button>
</view>
</view>
</view>
</uni-popup>
</template>

<script setup>
import {
		ref,
		computed,
		watch,
		onMounted
	} from 'vue';
import {
		useStore
	} from 'vuex'
import {onLoad}
from '@dcloudio/uni-app'
const props = defineProps({
params:{
Type:Object,
default:{
totype:'',
topage:'/pages/index/index'
			}
		}
	})
// 1. 获取 store 实例
const store = useStore();

// 2. 绑定 popup 组件的 ref (注意变量名要和 template 中的 ref 一致)
const popupRef = ref(null);

// 3. 计算属性:从 Vuex 中响应式获取显示状态
const isShow = computed(() => store.state.showLoginPopup);

// 监听 store 状态
watch(
// 监听源:一个返回你想监听的值的 Getter 函数
() => store.state.showLoginPopup,

// 监听回调
(newVal) => {
// 由于是直接监听 Vuex 状态,当状态变化时,这里会立即触发
console.log('watch 触发,新值:', newVal);

if (newVal) {
				popupRef.value.open();
			} else {
// 确保 popupRef.value 不为 null 再调用 close
if (popupRef.value) {
					popupRef.value.close();
				}
			}
		}
	);

// 5. 交互逻辑
const closePopup = () => {
// 调用 store 的 commit 或 dispatch 重置状态
		store.commit('SET_LOGIN_POPUP_STATUS', false);
	};

const emit = defineEmits(['login-success'])
const currentPage = ref({})
const doLogin = async () => {
try {
			uni.showLoading({
title: '登录中...',
mask: true
			})
const result = await store.dispatch('wechatLogin')
			uni.hideLoading()

emit('login-success')
if (!result?.userInfo?.get_avatar_and_nickname) { //如果用户没有完成昵称和头像的更新,就跳转到profile
console.log('props=',props)
let goto = '/pages/profile/profile'
				uni.navigateTo({
url: goto
				})
return
			}
await store.dispatch('closeLoginPopup')
		} catch (error) {
console.error('登录流程失败:', error);
			uni.showToast({
title: '登录失败',
icon: 'none'
			});
		}
	}
</script>

<style scoped>
.need-login {
background-color: #f5f7fa;
border-radius: 20rpx;
display: flex;
justify-content: center;
align-items: center;
padding: 40rpx;
	}

</style>

2.4 更新个人信息页面 pages/profile/profile.vue

<template>
<GlobalLoginPopup @login-success="refreshPage"/>
<view class="edit-page">
<!-- 头部 -->
<view class="page-header">
<view class="header-title">编辑信息</view>
<view class="header-subtitle">填写个人信息,开启精彩互动</view>
</view>

<!-- 表单区域 -->
<view class="form-container">
<uni-forms ref="valiForm" :rules="rules" :modelValue="valiFormData" labelWidth="100">
<view class="form-card">
<!-- 昵称 -->
<uni-forms-item label="昵称" required name="nickname">
<input type="nickname" v-model="valiFormData.nickname" placeholder="起一个响亮的昵称"
class="nickname-input"  />
</uni-forms-item>
<view class="form-divider"></view>

<!-- 头像 -->
<uni-forms-item label="头像" required name="avatar">
<button class="avatar-btn" size="mini" type="default" open-type="chooseAvatar"
							@chooseavatar="onChooseAvatar">
<view class="avatar-wrapper">
<image class="avatar" :src="valiFormData.avatar   || '/static/avatar.jpg'"
mode="aspectFill" />
</view>
</button>
</uni-forms-item>

<view class="form-divider"></view>

<!-- 自我介绍 -->
<uni-forms-item label="自我介绍" name="desc">
<uni-easyinput type="textarea" v-model="valiFormData.desc" placeholder="一句话概括自己,让大家更了解你"
placeholder-class="placeholder" :inputBorder="false" :maxlength="500" :autoHeight="true"
:styles="textareaStyles" />
<view class="text-count">
<text>{{ valiFormData.desc?.length || 0 }}/200</text>
</view>
</uni-forms-item>
</view>
</uni-forms>

<!-- 提交按钮 -->
<view class="submit-section">
<button class="submit-button" type="primary" @click="submit" :loading="loading">
<text v-if="!loading">保存信息</text>
<text v-else>保存中...</text>
</button>
</view>
</view>
</view>
</template>

<script setup>
import {ref,reactive,computed} from 'vue';
import {onShow,onLoad,onHide,} from '@dcloudio/uni-app';
import {useStore} from 'vuex';
import {uploadFile} from '../../api/common';
import {updateUserInfo} from '../../api/user';
import GlobalLoginPopup from '../../components/GlobalLoginPopup.vue';
const store = useStore();
const userInfo = computed(() => store.getters.userInfo);
const valiForm = ref(null);
const loading = ref(false);

// 表单数据
const valiFormData = ref({
nickname: userInfo.value.nickname || '',
avatar: userInfo.value.avatar || '',
desc: userInfo.value.desc || ''
	});
// 表单验证规则
const rules = reactive({
nickname: {
rules: [{
required: true,
errorMessage: '请填写昵称'
			}, {
minLength: 2,
maxLength: 20,
errorMessage: '昵称长度为2-20个字符'
			}]
		},
avatar: {
rules: [{
required: true,
errorMessage: '请选择头像'
			}]
		}
	});
onLoad((options) => {
console.log('profile.options',options)
	});

const refreshPage = ()=>{
//登录成功就返回上一页
		uni.navigateBack()
	}
// 选择头像
const onChooseAvatar = async (e) => {
const avatarUrl = e.detail.avatarUrl;
		uni.showLoading({
title: '上传中...',
mask: true
		});

try {
// 上传头像到服务器
const uploadResult = await uploadFile({
filePath: avatarUrl,
loading: false
			});

			valiFormData.value.avatar = uploadResult.url || avatarUrl;
			uni.showToast({
title: '头像上传成功',
icon: 'success'
			});
		} catch (error) {
console.error('头像上传失败:', error);
// 如果上传失败,先使用本地路径
			valiFormData.value.avatar = avatarUrl;
			uni.showToast({
title: '头像上传失败,使用本地头像',
icon: 'none'
			});
		} finally {
			uni.hideLoading();
		}
	};

// 提交表单
const submit = async () => {
try {
// 验证表单
await valiForm.value.validate();

			loading.value = true;
			uni.showLoading({
title: '保存中...',
mask: true
			});
console.log('valiFormData.value', valiFormData.value)
updateUserInfo(valiFormData.value).then((res) => {
console.log('updateUserInfo', res)
if (res) {
					loading.value = false
const userinfo = {
"avatar":valiFormData.value.avatar,
"nickname":valiFormData.value.nickname,
"get_avatar_and_nickname":true,
"desc":valiFormData.value.desc,
"position":valiFormData.value.position
					}
					store.dispatch('updateUserInfo', userinfo);
					store.dispatch('closeLoginPopup')
					uni.navigateBack()
				}
			}).catch(e => {
console.log(e)
			})
		} catch (err) {
console.error('保存失败:', err);
const errorMessage = err.errList?.[0]?.errorMessage || '请填写完整信息';
			uni.showToast({
title: errorMessage,
icon: 'none',
duration: 2000
			});
		} finally {
			loading.value = false;
			uni.hideLoading();
		}
	};
</script>

<style scoped>
.edit-page {
min-height: 100vh;
background: linear-gradient(180deg, #f9fafb 0%, #ffffff 100%);
padding-bottom: 40rpx;
	}
...
</style>

三、联合调试

3.1 启动服务端接口

图片

3.2 在HBuilderX里点击 运行->微信开发者工具,等待编译完成,会自动打开微信开发者工具(前提是安装了微信开发者工具)

图片

3.3 在微信开发者工具里查看最终效果

3.3.1 当有状态码为401的时候,就弹窗提示需要登录了

图片

3.3.2 点击“微信一键登录”,就会跳到 profile页面进行个人信息更新

图片

3.3.3 关键点要注意:

<!-- 昵称 -->
<uni-forms-item label="昵称" required name="nickname">
<input type="nickname" v-model="valiFormData.nickname" placeholder="起一个响亮的昵称"
class="nickname-input"  />
</uni-forms-item>
<view class="form-divider"></view>

<!-- 头像 -->
<uni-forms-item label="头像" required name="avatar">
<button class="avatar-btn" size="mini" type="default" open-type="chooseAvatar"
		@chooseavatar="onChooseAvatar">
<view class="avatar-wrapper">
<image class="avatar" :src="valiFormData.avatar   || '/static/avatar.jpg'"
mode="aspectFill" />
</view>
</button>
</uni-forms-item>
  • 昵称的input type必须是nickname,这样就会弹出微信昵称

  • 头像的open-type必须是chooseAvatar,就会弹出微信头像选择弹框

一句话总结:人生就像微信登录,你无法决定自己的openid,但你可以决定是否完善自己的头像与昵称。

*源码地址*

1、公众号“Codee君”回复“每日一Go”获取源码

2、pan.baidu.com/s/1B6pgLWfS…


如果您喜欢这篇文章,请您(点赞、分享、亮爱心),万分感谢!