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,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)
}
+9 -7
View File
@@ -10,13 +10,15 @@ import (
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidToken = errors.New("invalid token")
ErrMissingJWTSecret = errors.New("missing jwt secret")
ErrMiniProgramNotFound = errors.New("mini program not found")
ErrMiniProgramHasUsers = errors.New("mini program has users")
ErrMiniProgramAppIDUsed = errors.New("mini program app_id already exists")
ErrInvalidInput = errors.New("invalid input")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidToken = errors.New("invalid token")
ErrMissingJWTSecret = errors.New("missing jwt secret")
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")
)
type Claims struct {
+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
}