feat: 新增admin用户与会员管理接口

This commit is contained in:
root
2026-03-09 22:44:05 +08:00
parent 27617cd373
commit 386877da9a
6 changed files with 691 additions and 7 deletions
@@ -0,0 +1,120 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
adminservice "wx_service/internal/admin/service"
"wx_service/internal/model"
)
func (h *Handler) MembershipOverview(c *gin.Context) {
data, err := h.svc.MembershipOverview(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load membership overview failed"))
return
}
c.JSON(http.StatusOK, model.Success(data))
}
func (h *Handler) ListMembershipRedeemCodes(c *gin.Context) {
page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1")))
pageSize, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page_size", "20")))
data, err := h.svc.ListMembershipRedeemCodes(c.Request.Context(), adminservice.ListMembershipRedeemCodesQuery{
Page: page,
PageSize: pageSize,
Keyword: c.Query("keyword"),
Status: c.Query("status"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load redeem codes failed"))
return
}
c.JSON(http.StatusOK, model.Success(data))
}
type createMembershipRedeemCodesRequest struct {
PackageType string `json:"package_type"`
DurationDays int `json:"duration_days"`
Quantity int `json:"quantity"`
MaxUses int `json:"max_uses"`
ExpiresAt string `json:"expires_at"`
}
func (h *Handler) CreateMembershipRedeemCodes(c *gin.Context) {
var req createMembershipRedeemCodesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload"))
return
}
var expiresAt *time.Time
if strings.TrimSpace(req.ExpiresAt) != "" {
parsed, err := time.ParseInLocation("2006-01-02 15:04:05", req.ExpiresAt, time.Local)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "expires_at format should be YYYY-MM-DD HH:mm:ss"))
return
}
expiresAt = &parsed
}
codes, err := h.svc.CreateMembershipRedeemCodes(c.Request.Context(), adminservice.CreateMembershipRedeemCodesInput{
PackageType: req.PackageType,
DurationDays: req.DurationDays,
Quantity: req.Quantity,
MaxUses: req.MaxUses,
ExpiresAt: expiresAt,
})
if err != nil {
if errors.Is(err, adminservice.ErrInvalidInput) {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid input"))
return
}
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "create redeem codes failed"))
return
}
c.JSON(http.StatusOK, model.Success(gin.H{
"count": len(codes),
"codes": codes,
}))
}
type updateMembershipRedeemCodeStatusRequest struct {
Status string `json:"status"`
}
func (h *Handler) UpdateMembershipRedeemCodeStatus(c *gin.Context) {
id, err := parseUintID(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid redeem code id"))
return
}
var req updateMembershipRedeemCodeStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload"))
return
}
err = h.svc.UpdateMembershipRedeemCodeStatus(c.Request.Context(), id, req.Status)
if err != nil {
switch {
case errors.Is(err, adminservice.ErrMembershipRedeemCodeNotFound):
c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "redeem code not found"))
case errors.Is(err, adminservice.ErrInvalidInput):
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "status must be active/disabled"))
default:
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update redeem code status failed"))
}
return
}
c.JSON(http.StatusOK, model.Success(gin.H{"message": "更新成功"}))
}
+57
View File
@@ -0,0 +1,57 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
adminservice "wx_service/internal/admin/service"
"wx_service/internal/model"
)
func (h *Handler) ListUsers(c *gin.Context) {
page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1")))
pageSize, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page_size", "20")))
miniProgramID, _ := strconv.ParseUint(strings.TrimSpace(c.DefaultQuery("mini_program_id", "0")), 10, 64)
var isMember *bool
if raw := strings.TrimSpace(c.Query("is_member")); raw != "" {
value := raw == "1" || strings.EqualFold(raw, "true")
isMember = &value
}
data, err := h.svc.ListUsers(c.Request.Context(), adminservice.ListUsersQuery{
Page: page,
PageSize: pageSize,
MiniProgramID: uint(miniProgramID),
Keyword: c.Query("keyword"),
IsMember: isMember,
})
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load users failed"))
return
}
c.JSON(http.StatusOK, model.Success(data))
}
func (h *Handler) GetUserDetail(c *gin.Context) {
id, err := parseUintID(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid user id"))
return
}
data, err := h.svc.GetUserDetail(c.Request.Context(), id)
if err != nil {
if errors.Is(err, adminservice.ErrAdminUserNotFound) {
c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "user not found"))
return
}
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load user detail failed"))
return
}
c.JSON(http.StatusOK, model.Success(data))
}
@@ -0,0 +1,269 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
membershipmodel "wx_service/internal/membership/model"
"wx_service/internal/model"
"gorm.io/gorm"
)
type MembershipOverview struct {
TotalMembers int64 `json:"total_members"`
ActiveMembers int64 `json:"active_members"`
ExpiringSoon int64 `json:"expiring_soon"`
TodayRedeemed int64 `json:"today_redeemed"`
}
type ListMembershipRedeemCodesQuery struct {
Page int
PageSize int
Keyword string
Status string
}
type MembershipRedeemCodeItem struct {
ID uint `json:"id"`
Code string `json:"code"`
CodeSuffix string `json:"code_suffix"`
PackageType string `json:"package_type"`
DurationDays int `json:"duration_days"`
MaxUses int `json:"max_uses"`
UsedCount int `json:"used_count"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ListMembershipRedeemCodesResult struct {
List []MembershipRedeemCodeItem `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type CreateMembershipRedeemCodesInput struct {
PackageType string
DurationDays int
Quantity int
MaxUses int
ExpiresAt *time.Time
}
func (s *Service) MembershipOverview(ctx context.Context) (*MembershipOverview, error) {
result := &MembershipOverview{}
now := time.Now()
if err := s.db.WithContext(ctx).Model(&model.UserMembership{}).Count(&result.TotalMembers).Error; err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).
Model(&model.UserMembership{}).
Where("status = ?", "active").
Where("ends_at > ?", now).
Count(&result.ActiveMembers).Error; err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).
Model(&model.UserMembership{}).
Where("status = ?", "active").
Where("ends_at > ?", now).
Where("ends_at <= ?", now.AddDate(0, 0, 7)).
Count(&result.ExpiringSoon).Error; err != nil {
return nil, err
}
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
if err := s.db.WithContext(ctx).
Model(&membershipmodel.MembershipRedemption{}).
Where("created_at >= ?", todayStart).
Count(&result.TodayRedeemed).Error; err != nil {
return nil, err
}
return result, nil
}
func (s *Service) ListMembershipRedeemCodes(ctx context.Context, query ListMembershipRedeemCodesQuery) (*ListMembershipRedeemCodesResult, error) {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.Keyword = strings.TrimSpace(strings.ToUpper(query.Keyword))
query.Status = strings.TrimSpace(strings.ToLower(query.Status))
dbQuery := s.db.WithContext(ctx).Model(&membershipmodel.MembershipRedeemCode{})
if query.Keyword != "" {
dbQuery = dbQuery.Where("code_suffix LIKE ?", "%"+query.Keyword+"%")
}
if query.Status != "" {
dbQuery = dbQuery.Where("status = ?", query.Status)
}
var total int64
if err := dbQuery.Count(&total).Error; err != nil {
return nil, err
}
var rows []membershipmodel.MembershipRedeemCode
if total > 0 {
if err := dbQuery.Order("id DESC").
Limit(query.PageSize).
Offset((query.Page - 1) * query.PageSize).
Find(&rows).Error; err != nil {
return nil, err
}
}
result := make([]MembershipRedeemCodeItem, 0, len(rows))
for _, item := range rows {
status := item.Status
if item.ExpiresAt != nil && item.ExpiresAt.Before(time.Now()) {
status = "expired"
}
result = append(result, MembershipRedeemCodeItem{
ID: item.ID,
Code: "******" + item.CodeSuffix,
CodeSuffix: item.CodeSuffix,
PackageType: item.Plan,
DurationDays: item.DurationDays,
MaxUses: item.MaxUses,
UsedCount: item.UsedUses,
Status: status,
ExpiresAt: item.ExpiresAt,
CreatedAt: item.CreatedAt,
})
}
return &ListMembershipRedeemCodesResult{
List: result,
Total: total,
Page: query.Page,
PageSize: query.PageSize,
}, nil
}
func (s *Service) CreateMembershipRedeemCodes(ctx context.Context, input CreateMembershipRedeemCodesInput) ([]string, error) {
packageType := strings.TrimSpace(input.PackageType)
if packageType == "" {
packageType = "month"
}
if input.DurationDays <= 0 {
return nil, ErrInvalidInput
}
if input.Quantity <= 0 {
input.Quantity = 1
}
if input.Quantity > 500 {
input.Quantity = 500
}
if input.MaxUses <= 0 {
input.MaxUses = 1
}
codes := make([]string, 0, input.Quantity)
records := make([]membershipmodel.MembershipRedeemCode, 0, input.Quantity)
for len(codes) < input.Quantity {
code, err := generateAdminRedeemCode(20)
if err != nil {
return nil, err
}
hash := hashAdminRedeemCode(code)
suffix := suffixAdminRedeemCode(code, 6)
records = append(records, membershipmodel.MembershipRedeemCode{
CodeHash: hash,
CodeSuffix: suffix,
Plan: packageType,
DurationDays: input.DurationDays,
ExpiresAt: input.ExpiresAt,
MaxUses: input.MaxUses,
UsedUses: 0,
Status: "active",
})
codes = append(codes, code)
}
if err := s.db.WithContext(ctx).Create(&records).Error; err != nil {
return nil, fmt.Errorf("save redeem codes: %w", err)
}
return codes, nil
}
func (s *Service) UpdateMembershipRedeemCodeStatus(ctx context.Context, id uint, status string) error {
status = strings.TrimSpace(strings.ToLower(status))
if status != "active" && status != "disabled" {
return ErrInvalidInput
}
result := s.db.WithContext(ctx).
Model(&membershipmodel.MembershipRedeemCode{}).
Where("id = ?", id).
Update("status", status)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrMembershipRedeemCodeNotFound
}
return nil
}
func generateAdminRedeemCode(length int) (string, error) {
if length <= 0 {
length = 16
}
const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return "", err
}
out := make([]byte, length)
for i, b := range buf {
out[i] = alphabet[int(b)%len(alphabet)]
}
return string(out), nil
}
func hashAdminRedeemCode(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
func suffixAdminRedeemCode(code string, n int) string {
if n <= 0 {
return ""
}
if len(code) <= n {
return code
}
return code[len(code)-n:]
}
func (s *Service) EnsureMembershipRedeemCode(id uint) error {
var count int64
if err := s.db.Model(&membershipmodel.MembershipRedeemCode{}).Where("id = ?", id).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return ErrMembershipRedeemCodeNotFound
}
return nil
}
func isNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
+2
View File
@@ -16,6 +16,8 @@ var (
ErrMiniProgramNotFound = errors.New("mini program not found")
ErrMiniProgramHasUsers = errors.New("mini program has users")
ErrMiniProgramAppIDUsed = errors.New("mini program app_id already exists")
ErrAdminUserNotFound = errors.New("admin user not found")
ErrMembershipRedeemCodeNotFound = errors.New("membership redeem code not found")
ErrInvalidInput = errors.New("invalid input")
)
+228
View File
@@ -0,0 +1,228 @@
package service
import (
"context"
"errors"
"strings"
"time"
expirymodel "wx_service/internal/expiry/model"
membershipmodel "wx_service/internal/membership/model"
"wx_service/internal/model"
rmmodel "wx_service/internal/remove_watermark/model"
"gorm.io/gorm"
)
type ListUsersQuery struct {
Page int
PageSize int
MiniProgramID uint
Keyword string
IsMember *bool
}
type UserListItem struct {
ID uint `json:"id"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
Phone string `json:"phone"`
MiniProgramID uint `json:"mini_program_id"`
MiniProgramName string `json:"mini_program_name"`
IsMember bool `json:"is_member"`
CreatedAt time.Time `json:"created_at"`
}
type ListUsersResult struct {
List []UserListItem `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type UserMembershipInfo struct {
IsMember bool `json:"is_member"`
PackageType string `json:"package_type"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type UserDetail struct {
ID uint `json:"id"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
Phone string `json:"phone"`
OpenID string `json:"open_id"`
Gender int `json:"gender"`
MiniProgramID uint `json:"mini_program_id"`
MiniProgramName string `json:"mini_program_name"`
IsMember bool `json:"is_member"`
CreatedAt time.Time `json:"created_at"`
Membership UserMembershipInfo `json:"membership"`
Stats map[string]int64 `json:"stats"`
}
func (s *Service) ListUsers(ctx context.Context, query ListUsersQuery) (*ListUsersResult, error) {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.Keyword = strings.TrimSpace(query.Keyword)
now := time.Now()
dbQuery := s.db.WithContext(ctx).Model(&model.User{})
if query.MiniProgramID > 0 {
dbQuery = dbQuery.Where("mini_program_id = ?", query.MiniProgramID)
}
if query.Keyword != "" {
likeKeyword := "%" + query.Keyword + "%"
dbQuery = dbQuery.Where("nick_name LIKE ? OR phone LIKE ?", likeKeyword, likeKeyword)
}
if query.IsMember != nil {
subQuery := s.db.WithContext(ctx).Table("user_memberships AS um").
Select("1").
Where("um.user_id = users.id").
Where("um.status = ?", "active").
Where("um.ends_at > ?", now)
if *query.IsMember {
dbQuery = dbQuery.Where("EXISTS (?)", subQuery)
} else {
dbQuery = dbQuery.Where("NOT EXISTS (?)", subQuery)
}
}
var total int64
if err := dbQuery.Count(&total).Error; err != nil {
return nil, err
}
var users []model.User
if total > 0 {
if err := dbQuery.Order("id DESC").
Limit(query.PageSize).
Offset((query.Page - 1) * query.PageSize).
Find(&users).Error; err != nil {
return nil, err
}
}
miniProgramIDs := make([]uint, 0, len(users))
userIDs := make([]uint, 0, len(users))
for _, user := range users {
miniProgramIDs = append(miniProgramIDs, user.MiniProgramID)
userIDs = append(userIDs, user.ID)
}
miniProgramNameMap := map[uint]string{}
if len(miniProgramIDs) > 0 {
var miniPrograms []model.MiniProgram
if err := s.db.WithContext(ctx).
Select("id", "name").
Where("id IN ?", miniProgramIDs).
Find(&miniPrograms).Error; err != nil {
return nil, err
}
for _, item := range miniPrograms {
miniProgramNameMap[item.ID] = item.Name
}
}
memberUserMap := map[uint]bool{}
if len(userIDs) > 0 {
var memberships []model.UserMembership
if err := s.db.WithContext(ctx).
Select("user_id").
Where("user_id IN ?", userIDs).
Where("status = ?", "active").
Where("ends_at > ?", now).
Find(&memberships).Error; err != nil {
return nil, err
}
for _, membership := range memberships {
memberUserMap[membership.UserID] = true
}
}
result := make([]UserListItem, 0, len(users))
for _, user := range users {
result = append(result, UserListItem{
ID: user.ID,
Nickname: user.NickName,
AvatarURL: user.AvatarURL,
Phone: user.Phone,
MiniProgramID: user.MiniProgramID,
MiniProgramName: miniProgramNameMap[user.MiniProgramID],
IsMember: memberUserMap[user.ID],
CreatedAt: user.CreatedAt,
})
}
return &ListUsersResult{
List: result,
Total: total,
Page: query.Page,
PageSize: query.PageSize,
}, nil
}
func (s *Service) GetUserDetail(ctx context.Context, userID uint) (*UserDetail, error) {
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAdminUserNotFound
}
return nil, err
}
var miniProgram model.MiniProgram
_ = s.db.WithContext(ctx).Select("id", "name").Where("id = ?", user.MiniProgramID).First(&miniProgram).Error
now := time.Now()
membershipInfo := UserMembershipInfo{IsMember: false}
var membership model.UserMembership
if err := s.db.WithContext(ctx).
Where("user_id = ?", user.ID).
Where("status = ?", "active").
Where("ends_at > ?", now).
Order("ends_at DESC").
First(&membership).Error; err == nil {
expiresAt := membership.EndsAt
membershipInfo = UserMembershipInfo{
IsMember: true,
PackageType: membership.Plan,
ExpiresAt: &expiresAt,
}
}
stats := map[string]int64{}
var expiryCount int64
_ = s.db.WithContext(ctx).Model(&expirymodel.ExpiryItem{}).Where("user_id = ?", user.ID).Count(&expiryCount).Error
stats["expiry_items"] = expiryCount
var watermarkCount int64
_ = s.db.WithContext(ctx).Model(&rmmodel.VideoParseLog{}).Where("user_id = ?", user.ID).Count(&watermarkCount).Error
stats["watermark_tasks"] = watermarkCount
var redemptionCount int64
_ = s.db.WithContext(ctx).Model(&membershipmodel.MembershipRedemption{}).Where("user_id = ?", user.ID).Count(&redemptionCount).Error
stats["membership_redemptions"] = redemptionCount
return &UserDetail{
ID: user.ID,
Nickname: user.NickName,
AvatarURL: user.AvatarURL,
Phone: user.Phone,
OpenID: user.OpenID,
Gender: user.Gender,
MiniProgramID: user.MiniProgramID,
MiniProgramName: miniProgram.Name,
IsMember: membershipInfo.IsMember,
CreatedAt: user.CreatedAt,
Membership: membershipInfo,
Stats: stats,
}, nil
}
+8
View File
@@ -31,6 +31,14 @@ func registerAdminRoutes(router *gin.Engine, handler *adminhandler.Handler) {
protected.POST("/mini-programs", handler.CreateMiniProgram)
protected.PUT("/mini-programs/:id", handler.UpdateMiniProgram)
protected.DELETE("/mini-programs/:id", handler.DeleteMiniProgram)
protected.GET("/users", handler.ListUsers)
protected.GET("/users/:id", handler.GetUserDetail)
protected.GET("/memberships/overview", handler.MembershipOverview)
protected.GET("/memberships/redeem-codes", handler.ListMembershipRedeemCodes)
protected.POST("/memberships/redeem-codes", handler.CreateMembershipRedeemCodes)
protected.POST("/memberships/redeem-codes/:id/status", handler.UpdateMembershipRedeemCodeStatus)
}
}
}