This commit is contained in:
2026-10-25 21:00:08 +08:00
parent 30e5a6bed0
commit 039528e759
9 changed files with 534 additions and 12 deletions
+120 -2
View File
@@ -2,9 +2,12 @@
package app1hdl
import (
"strconv"
"github.com/gin-gonic/gin"
"portal-mini-go/internal/middleware"
"portal-mini-go/internal/model"
"portal-mini-go/internal/pkg/errors"
"portal-mini-go/internal/pkg/response"
app1svc "portal-mini-go/internal/service/miniapp/app1"
@@ -34,10 +37,125 @@ func (h *Handler) Login(c *gin.Context) {
c.Error(err)
return
}
response.OK(c, gin.H{"token": token, "user": u})
response.OK(c, gin.H{"token": token, "user": toProfileView(u)})
}
// Logout 注销当前用户会话。需在用户会话中间件通过后调用。
func (h *Handler) Logout(c *gin.Context) {
if err := h.svc.Logout(c.Request.Context(), middleware.SessionToken(c)); err != nil {
c.Error(err)
return
}
response.OK(c, nil)
}
// Profile 获取当前登录用户资料。
func (h *Handler) Profile(c *gin.Context) {
u, err := h.svc.GetProfile(c.Request.Context(), middleware.MiniID(c), middleware.UserID(c))
if err != nil {
c.Error(err)
return
}
response.OK(c, toProfileView(u))
}
type UpdateProfileReq struct {
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// UpdateProfile 更新当前登录用户的昵称/头像,留空字段不更新。
func (h *Handler) UpdateProfile(c *gin.Context) {
var req UpdateProfileReq
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.ErrBadRequest)
return
}
if err := h.svc.UpdateProfile(c.Request.Context(), middleware.MiniID(c), middleware.UserID(c), req.Nickname, req.Avatar); err != nil {
c.Error(err)
return
}
response.OK(c, nil)
}
// GetUser 获取同小程序下指定用户(仅公开字段)。
func (h *Handler) GetUser(c *gin.Context) {
id, ok := parseUserID(c)
if !ok {
return
}
u, err := h.svc.GetUser(c.Request.Context(), middleware.MiniID(c), id)
if err != nil {
c.Error(err)
return
}
response.OK(c, toPublicUserView(u))
}
// ListUsers 分页列出同小程序下的用户。
func (h *Handler) ListUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
list, total, err := h.svc.ListUsers(c.Request.Context(), middleware.MiniID(c), page, size)
if err != nil {
c.Error(err)
return
}
views := make([]PublicUserView, 0, len(list))
for i := range list {
views = append(views, toPublicUserView(&list[i]))
}
response.OK(c, gin.H{"list": views, "total": total, "page": page, "size": size})
}
// ProfileView 当前用户自身的资料视图(含自身 openid/unionid)。
type ProfileView struct {
ID uint64 `json:"id"`
OpenID string `json:"openid"`
UnionID string `json:"union_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
CreatedAt int64 `json:"created_at"`
}
// PublicUserView 对外的用户公开视图,剔除 openid/unionid 等敏感字段。
type PublicUserView struct {
ID uint64 `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
func toProfileView(u *model.User) ProfileView {
return ProfileView{
ID: u.ID,
OpenID: u.OpenID,
UnionID: u.UnionID,
Nickname: u.Nickname,
Avatar: u.Avatar,
CreatedAt: u.CreatedAt.Unix(),
}
}
func toPublicUserView(u *model.User) PublicUserView {
return PublicUserView{ID: u.ID, Nickname: u.Nickname, Avatar: u.Avatar}
}
func parseUserID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.Error(errors.ErrBadRequest)
return 0, false
}
return id, true
}
// Register 注册 app1 的路由。g 已带 /api/ma 前缀与 MiniAppAuth 中间件(校验请求头 app_id)。
func Register(g *gin.RouterGroup, h *Handler) {
// userAuth 为用户会话中间件,用于保护需要登录态的接口。
func Register(g *gin.RouterGroup, h *Handler, userAuth gin.HandlerFunc) {
g.POST("/login", h.Login)
g.POST("/logout", userAuth, h.Logout)
g.GET("/profile", userAuth, h.Profile)
g.PUT("/profile", userAuth, h.UpdateProfile)
g.GET("/users/:id", userAuth, h.GetUser)
g.GET("/users", userAuth, h.ListUsers)
}
+49
View File
@@ -2,20 +2,28 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
"portal-mini-go/internal/pkg/errors"
"portal-mini-go/internal/pkg/session"
"portal-mini-go/internal/repository/miniapp"
)
const (
appIDCtxKey = "app_id"
miniIDCtxKey = "mini_id"
userIDCtxKey = "user_id"
)
// headerAppID 是小程序在请求头中携带的微信 AppID 字段名。
const HeaderAppID = "X-App-ID"
// HeaderSessionToken 是小程序用户会话 token 的请求头字段名。
// 也兼容标准 Authorization: Bearer <token> 形式。
const HeaderSessionToken = "X-Session-Token"
// MiniAppAuth 校验请求头中的 app_id 是否对应一个已启用且未删除的被纳管小程序,
// 通过则将 app_id 与小程序的数据库主键注入 context,供后续 handler/service 使用。
// 用户身份最终由 (mini_id, open_id) 唯一确定。
@@ -58,3 +66,44 @@ func MiniID(c *gin.Context) uint64 {
}
return 0
}
// MiniUserAuth 校验小程序用户会话 token(由 Login 签发、存于 session.Store),
// 通过后将当前用户主键注入 context。必须在 MiniAppAuth 之后使用,
// 依赖其注入的 mini_id 确保会话归属到正确的小程序。
// token 来源:请求头 Authorization: Bearer <token> 或 X-Session-Token。
func MiniUserAuth(store session.Store) gin.HandlerFunc {
return func(c *gin.Context) {
token := SessionToken(c)
if token == "" {
c.Error(errors.ErrUnauthorized)
c.Abort()
return
}
userID, err := store.Get(c.Request.Context(), session.MaSessionKey(token))
if err != nil {
c.Error(errors.ErrUnauthorized)
c.Abort()
return
}
c.Set(userIDCtxKey, userID)
c.Next()
}
}
// UserID 从 gin.Context 取出当前已认证用户的主键。
func UserID(c *gin.Context) uint64 {
if v, ok := c.Get(userIDCtxKey); ok {
if id, ok := v.(uint64); ok {
return id
}
}
return 0
}
// SessionToken 从请求头提取小程序用户会话 token。
func SessionToken(c *gin.Context) string {
if h := c.GetHeader("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
}
return c.GetHeader(HeaderSessionToken)
}
+7
View File
@@ -14,8 +14,15 @@ type User struct {
UnionID string `gorm:"type:varchar(64);index" json:"union_id"`
Nickname string `gorm:"type:varchar(128)" json:"nickname"`
Avatar string `gorm:"type:varchar(512)" json:"avatar"`
Status int8 `gorm:"column:status;type:tinyint;not null;default:1" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (User) TableName() string { return "users" }
// 用户状态:与管理端小程序一致,0 禁用、1 正常。
const (
UserStatusDisabled int8 = 0
UserStatusEnabled int8 = 1
)
+6
View File
@@ -17,6 +17,12 @@ type Store interface {
Delete(ctx context.Context, key string) error
}
// MaSessionPrefix 是小程序用户会话键前缀。
const MaSessionPrefix = "ma:session:"
// MaSessionKey 拼接小程序用户会话键(token 为 Login 签发的会话凭证)。
func MaSessionKey(token string) string { return MaSessionPrefix + token }
// MemoryStore 进程内会话存储,适用于无 Redis 的本地/测试环境。
// 注意:多实例部署时各进程不共享,仅供单实例使用。
type MemoryStore struct {
+93 -1
View File
@@ -46,11 +46,103 @@ func (r *Repository) GetByID(ctx context.Context, miniID uint64, id uint64) (*mo
}
// UpdateProfile 更新用户资料,限定在 mini_id 范围内。
// nickname / avatar 为空字符串时视为不更新该字段,便于部分更新。
func (r *Repository) UpdateProfile(ctx context.Context, miniID uint64, id uint64, nickname, avatar string) error {
cols := make(map[string]interface{})
if nickname != "" {
cols["nickname"] = nickname
}
if avatar != "" {
cols["avatar"] = avatar
}
if len(cols) == 0 {
return nil
}
res := r.db.WithContext(ctx).
Model(&model.User{}).
Where("mini_id = ? AND id = ?", miniID, id).
Updates(map[string]interface{}{"nickname": nickname, "avatar": avatar})
Updates(cols)
if res.Error != nil {
return errors.Wrap(50000, "update user", res.Error)
}
if res.RowsAffected == 0 {
return errors.ErrNotFound
}
return nil
}
// List 分页查询某小程序下的用户,按 ID 倒序。
func (r *Repository) List(ctx context.Context, miniID uint64, page, size int) ([]model.User, int64, error) {
q := r.db.WithContext(ctx).Model(&model.User{}).Where("mini_id = ?", miniID)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(50000, "count users", err)
}
var list []model.User
if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
return nil, 0, errors.Wrap(50000, "list users", err)
}
return list, total, nil
}
// AdminListFilter 后台用户列表过滤条件。MiniID 为 0 表示不限小程序。
type AdminListFilter struct {
MiniID uint64
Keyword string
Page int
Size int
}
// AdminList 后台分页查询用户,支持按小程序与关键字(昵称 / OpenID)过滤。
func (r *Repository) AdminList(ctx context.Context, f AdminListFilter) ([]model.User, int64, error) {
q := r.db.WithContext(ctx).Model(&model.User{})
if f.MiniID != 0 {
q = q.Where("mini_id = ?", f.MiniID)
}
if f.Keyword != "" {
kw := "%" + f.Keyword + "%"
q = q.Where("nickname LIKE ? OR openid LIKE ?", kw, kw)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(50000, "count users", err)
}
var list []model.User
if err := q.Order("id DESC").Offset((f.Page - 1) * f.Size).Limit(f.Size).Find(&list).Error; err != nil {
return nil, 0, errors.Wrap(50000, "list users", err)
}
return list, total, nil
}
// AdminGet 按 ID 查询用户(后台视角,不限小程序)。
func (r *Repository) AdminGet(ctx context.Context, id uint64) (*model.User, error) {
var u model.User
err := r.db.WithContext(ctx).Where("id = ?", id).First(&u).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.ErrNotFound
}
return nil, errors.Wrap(50000, "query user", err)
}
return &u, nil
}
// AdminUpdate 后台按 ID 更新用户资料与状态。nil 字段表示不更新该字段。
func (r *Repository) AdminUpdate(ctx context.Context, id uint64, nickname, avatar *string, status *int8) error {
cols := map[string]interface{}{}
if nickname != nil {
cols["nickname"] = *nickname
}
if avatar != nil {
cols["avatar"] = *avatar
}
if status != nil {
cols["status"] = *status
}
if len(cols) == 0 {
return nil
}
res := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(cols)
if res.Error != nil {
return errors.Wrap(50000, "update user", res.Error)
}
+7 -1
View File
@@ -10,6 +10,7 @@ import (
adminhdl "portal-mini-go/internal/handler/admin"
miniapphdl "portal-mini-go/internal/handler/admin/miniapp"
userhdl "portal-mini-go/internal/handler/admin/user"
app1hdl "portal-mini-go/internal/handler/miniapp/app1"
"portal-mini-go/internal/middleware"
"portal-mini-go/internal/pkg/config"
@@ -19,6 +20,7 @@ import (
miniapprepo "portal-mini-go/internal/repository/miniapp"
"portal-mini-go/internal/repository/user"
miniappsvc "portal-mini-go/internal/service/admin/miniapp"
usersvc "portal-mini-go/internal/service/admin/user"
app1svc "portal-mini-go/internal/service/miniapp/app1"
)
@@ -45,7 +47,7 @@ func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine {
ma.Use(middleware.MiniAppAuth(miniRepo))
{
app1Svc := app1svc.New(userRepo, miniRepo, wx, store)
app1hdl.Register(ma, app1hdl.NewHandler(app1Svc))
app1hdl.Register(ma, app1hdl.NewHandler(app1Svc), middleware.MiniUserAuth(store))
// 新增小程序时,在此依次注册 app2..app10:
// app2Svc := app2svc.New(userRepo, miniRepo, wx, store)
// app2hdl.Register(ma, app2hdl.NewHandler(app2Svc))
@@ -60,6 +62,10 @@ func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine {
// 小程序管理:被后台纳管的微信小程序配置(增删改查)。
miniSvc := miniappsvc.New(miniRepo)
miniapphdl.Register(ad, miniapphdl.NewHandler(miniSvc))
// 用户管理:各小程序端用户(列表、详情、编辑、禁用/启用)。
userSvc := usersvc.New(userRepo)
userhdl.Register(ad, userhdl.NewHandler(userSvc))
}
return r
+35 -3
View File
@@ -16,8 +16,6 @@ import (
"portal-mini-go/internal/repository/user"
)
const sessionKeyPrefix = "ma:session:"
// Service app1 业务服务,依赖用户仓储、小程序仓储、微信客户端与会话存储。
type Service struct {
userRepo *user.Repository
@@ -49,12 +47,46 @@ func (s *Service) Login(ctx context.Context, appID, code string) (string, *model
if err != nil {
return "", nil, err
}
if err := s.store.Set(ctx, sessionKeyPrefix+token, u.ID, 7*24*time.Hour); err != nil {
if err := s.store.Set(ctx, session.MaSessionKey(token), u.ID, 7*24*time.Hour); err != nil {
return "", nil, errors.Wrap(50000, "save session", err)
}
return token, u, nil
}
// GetProfile 获取当前登录用户资料(绑定到 mini_id,杜绝跨小程序访问)。
func (s *Service) GetProfile(ctx context.Context, miniID, userID uint64) (*model.User, error) {
return s.userRepo.GetByID(ctx, miniID, userID)
}
// UpdateProfile 更新当前登录用户的昵称/头像,为空字段不更新。
func (s *Service) UpdateProfile(ctx context.Context, miniID, userID uint64, nickname, avatar string) error {
return s.userRepo.UpdateProfile(ctx, miniID, userID, nickname, avatar)
}
// GetUser 获取同小程序下指定用户(对外仅暴露非敏感字段,由 handler 控制)。
func (s *Service) GetUser(ctx context.Context, miniID, id uint64) (*model.User, error) {
return s.userRepo.GetByID(ctx, miniID, id)
}
// ListUsers 分页列出同小程序下的用户。
func (s *Service) ListUsers(ctx context.Context, miniID uint64, page, size int) ([]model.User, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
return s.userRepo.List(ctx, miniID, page, size)
}
// Logout 注销当前用户会话(删除会话 token)。
func (s *Service) Logout(ctx context.Context, token string) error {
if err := s.store.Delete(ctx, session.MaSessionKey(token)); err != nil {
return errors.Wrap(50000, "delete session", err)
}
return nil
}
func newSessionToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {