大部分微信小程序都有一个基本的登录需求,最好的方式当然是利用微信登录让用户进入得更丝滑和顺畅。话不多说,直接开干,前端我们选用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(¶ms); 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”获取源码
如果您喜欢这篇文章,请您(点赞、分享、亮爱心),万分感谢!