Files
Go-Web-Template/server/api/v1/admin/app_users.go
2026-04-23 15:29:07 +08:00

705 lines
21 KiB
Go

package admin
import (
"errors"
"strconv"
"strings"
"time"
"git.echol.cn/loser/Go-Web-Template/server/global"
appModel "git.echol.cn/loser/Go-Web-Template/server/model/app"
"git.echol.cn/loser/Go-Web-Template/server/model/common/response"
systemSvc "git.echol.cn/loser/Go-Web-Template/server/service/system"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AppUsersApi struct{}
type appUserListItem struct {
ID uint `json:"id"`
UUID string `json:"uuid"`
Username string `json:"username"`
Enable int `json:"enable"`
NickName string `json:"nickName,omitempty"`
AccountBalanceLi int64 `json:"accountBalanceLi"`
GameCoinBalanceLi int64 `json:"gameCoinBalanceLi"`
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
LastActiveAt *time.Time `json:"lastActiveAt,omitempty"`
IsOnline bool `json:"isOnline"`
}
type createAppUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=6,max=128"`
Enable *bool `json:"enable"`
NickName string `json:"nickName"`
WelcomePhrase string `json:"welcomePhrase"`
}
type updateAppUserRequest struct {
Enable *bool `json:"enable"`
WelcomePhrase *string `json:"welcomePhrase"`
}
type resetPasswordRequest struct {
NewPassword string `json:"newPassword" binding:"required,min=6,max=128"`
}
type appUserDetailItem struct {
ID uint `json:"id"`
UUID string `json:"uuid"`
Username string `json:"username"`
Enable int `json:"enable"`
NickName string `json:"nickName,omitempty"`
WelcomePhrase string `json:"welcomePhrase,omitempty"`
}
type appUserOverviewResponse struct {
User struct {
ID uint `json:"id"`
UUID string `json:"uuid"`
Username string `json:"username"`
Enable int `json:"enable"`
NickName string `json:"nickName,omitempty"`
} `json:"user"`
Asset struct {
AccountBalanceLi int64 `json:"accountBalanceLi"`
GameCoinBalanceLi int64 `json:"gameCoinBalanceLi"`
} `json:"asset"`
Activity struct {
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
LastActiveAt *time.Time `json:"lastActiveAt,omitempty"`
LastActiveIP string `json:"lastActiveIp,omitempty"`
IsOnline bool `json:"isOnline"`
} `json:"activity"`
RecentLoginLogs []appModel.AppLoginLog `json:"recentLoginLogs"`
RecentTx []appModel.AppAssetTransaction `json:"recentTx"`
Invite struct {
InviterUserID *uint `json:"inviterUserId,omitempty"`
InviterUsername string `json:"inviterUsername,omitempty"`
InvitedCount int64 `json:"invitedCount"`
HasUnusedCode bool `json:"hasUnusedCode"`
UnusedCodeLast4 string `json:"unusedCodeLast4,omitempty"`
UnusedExpiresAt *time.Time `json:"unusedExpiresAt,omitempty"`
} `json:"invite"`
}
// GetAppUsersList
// @Tags AppUsersAdmin
// @Summary 后台获取玩家列表
// @Security ApiKeyAuth
// @Produce application/json
// @Param page query int false "页码" default(1)
// @Param pageSize query int false "每页大小" default(10)
// @Param keyword query string false "用户名关键字"
// @Param isOnline query bool false "是否在线(基于 lastActiveAt 与在线窗口判断)"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
// @Router /admin/app-users [get]
func (a *AppUsersApi) GetAppUsersList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 10
}
keyword := c.Query("keyword")
var isOnlineFilter *bool
if raw := strings.TrimSpace(c.Query("isOnline")); raw != "" {
if v, err := strconv.ParseBool(raw); err == nil {
isOnlineFilter = &v
}
}
db := global.GVA_DB.Model(&appModel.AppUser{})
if keyword != "" {
db = db.Where("username ILIKE ?", "%"+keyword+"%")
}
// 在线状态过滤:通过 join 活跃表,按窗口阈值判断
windowMin := systemSvc.GetAppOnlineWindowMinutes()
now := time.Now()
if isOnlineFilter != nil {
threshold := now.Add(-time.Duration(windowMin) * time.Minute)
db = db.Joins("LEFT JOIN app_user_activity act ON act.app_user_id = app_users.id")
db = db.Select("app_users.*")
if *isOnlineFilter {
db = db.Where("act.last_active_at IS NOT NULL AND act.last_active_at >= ?", threshold)
} else {
db = db.Where("act.last_active_at IS NULL OR act.last_active_at < ?", threshold)
}
}
var total int64
if err := db.Count(&total).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
var users []appModel.AppUser
if err := db.Order("id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&users).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
ids := make([]uint, 0, len(users))
for _, u := range users {
ids = append(ids, u.ID)
}
profiles := map[uint]appModel.AppUserProfile{}
if len(ids) > 0 {
var list []appModel.AppUserProfile
_ = global.GVA_DB.Where("app_user_id IN ?", ids).Find(&list).Error
for _, p := range list {
profiles[p.AppUserID] = p
}
}
assets := map[uint]appModel.AppUserAsset{}
if len(ids) > 0 {
var list []appModel.AppUserAsset
_ = global.GVA_DB.Where("app_user_id IN ?", ids).Find(&list).Error
for _, a := range list {
assets[a.AppUserID] = a
}
}
activities := map[uint]appModel.AppUserActivity{}
if len(ids) > 0 {
var list []appModel.AppUserActivity
_ = global.GVA_DB.Where("app_user_id IN ?", ids).Find(&list).Error
for _, a := range list {
activities[a.AppUserID] = a
}
}
items := make([]appUserListItem, 0, len(users))
for _, u := range users {
p := profiles[u.ID]
asset := assets[u.ID]
act := activities[u.ID]
isOnline := false
if act.LastActiveAt != nil && windowMin > 0 {
isOnline = now.Sub(*act.LastActiveAt) <= time.Duration(windowMin)*time.Minute
}
items = append(items, appUserListItem{
ID: u.ID,
UUID: u.UUID.String(),
Username: u.Username,
Enable: u.Enable,
NickName: p.NickName,
AccountBalanceLi: asset.AccountBalanceLi,
GameCoinBalanceLi: asset.GameCoinBalanceLi,
LastLoginAt: act.LastLoginAt,
LastActiveAt: act.LastActiveAt,
IsOnline: isOnline,
})
}
response.OkWithDetailed(response.PageResult{
List: items,
Total: total,
Page: page,
PageSize: pageSize,
}, "获取成功", c)
}
type rechargeRequest struct {
AssetType string `json:"assetType" binding:"required"`
RechargeType string `json:"rechargeType" binding:"required"`
DeltaLi int64 `json:"deltaLi" binding:"required"`
Reason string `json:"reason" binding:"required"`
}
// RechargeAppUser
// @Tags AppUsersAdmin
// @Summary 后台手动充值(账户余额/游戏币,单位:厘)
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "玩家ID"
// @Param data body rechargeRequest true "充值信息"
// @Success 200 {object} response.Response{msg=string} "充值成功"
// @Router /admin/app-users/{id}/recharge [post]
func (a *AppUsersApi) RechargeAppUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var req rechargeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
req.AssetType = strings.TrimSpace(req.AssetType)
req.RechargeType = strings.TrimSpace(req.RechargeType)
req.Reason = strings.TrimSpace(req.Reason)
if req.Reason == "" {
response.FailWithMessage("原因不能为空", c)
return
}
if req.DeltaLi == 0 {
response.FailWithMessage("变动金额不能为 0", c)
return
}
if req.AssetType != "account" && req.AssetType != "game_coin" {
response.FailWithMessage("assetType 不合法", c)
return
}
if req.RechargeType != "offline" && req.RechargeType != "online_not_arrived" && req.RechargeType != "activity_subsidy" && req.RechargeType != "manual_adjustment" {
response.FailWithMessage("rechargeType 不合法", c)
return
}
if req.RechargeType == "activity_subsidy" && req.AssetType != "game_coin" {
response.FailWithMessage("活动补贴仅支持充值游戏币", c)
return
}
operatorID := utils.GetUserID(c)
if operatorID == 0 {
response.FailWithMessage("未登录", c)
return
}
if err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
// 确保玩家存在
var user appModel.AppUser
if err := tx.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("用户不存在")
}
return err
}
var asset appModel.AppUserAsset
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("app_user_id = ?", user.ID).First(&asset).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
asset = appModel.AppUserAsset{AppUserID: user.ID}
if err := tx.Create(&asset).Error; err != nil {
return err
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("app_user_id = ?", user.ID).First(&asset).Error; err != nil {
return err
}
} else if err != nil {
return err
}
var before, after int64
if req.AssetType == "account" {
before = asset.AccountBalanceLi
after = before + req.DeltaLi
if after < 0 {
return errors.New("账户余额不足")
}
asset.AccountBalanceLi = after
if err := tx.Model(&asset).Select("account_balance_li").Updates(&asset).Error; err != nil {
return err
}
} else {
before = asset.GameCoinBalanceLi
after = before + req.DeltaLi
if after < 0 {
return errors.New("游戏币余额不足")
}
asset.GameCoinBalanceLi = after
if err := tx.Model(&asset).Select("game_coin_balance_li").Updates(&asset).Error; err != nil {
return err
}
}
txRec := appModel.AppAssetTransaction{
AppUserID: user.ID,
AssetType: appModel.AppAssetType(req.AssetType),
RechargeType: appModel.AppRechargeType(req.RechargeType),
DeltaLi: req.DeltaLi,
BeforeLi: before,
AfterLi: after,
Reason: req.Reason,
OperatorUserID: operatorID,
CreatedAt: time.Now(),
}
return tx.Create(&txRec).Error
}); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("充值成功", c)
}
// GetAppUserById
// @Tags AppUsersAdmin
// @Summary 后台获取玩家详情
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "玩家ID"
// @Success 200 {object} response.Response{data=appUserDetailItem,msg=string} "获取成功"
// @Router /admin/app-users/{id} [get]
func (a *AppUsersApi) GetAppUserById(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var user appModel.AppUser
if err := global.GVA_DB.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.FailWithMessage("用户不存在", c)
return
}
response.FailWithMessage(err.Error(), c)
return
}
var profile appModel.AppUserProfile
_ = global.GVA_DB.Where("app_user_id = ?", user.ID).First(&profile).Error
response.OkWithData(appUserDetailItem{
ID: user.ID,
UUID: user.UUID.String(),
Username: user.Username,
Enable: user.Enable,
NickName: profile.NickName,
WelcomePhrase: profile.WelcomePhrase,
}, c)
}
// GetAppUserOverview
// @Tags AppUsersAdmin
// @Summary 后台获取玩家聚合详情(概览)
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "玩家ID"
// @Success 200 {object} response.Response{data=appUserOverviewResponse,msg=string} "获取成功"
// @Router /admin/app-users/{id}/overview [get]
func (a *AppUsersApi) GetAppUserOverview(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var user appModel.AppUser
if err := global.GVA_DB.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.FailWithMessage("用户不存在", c)
return
}
response.FailWithMessage(err.Error(), c)
return
}
var profile appModel.AppUserProfile
_ = global.GVA_DB.Where("app_user_id = ?", user.ID).First(&profile).Error
var asset appModel.AppUserAsset
_ = global.GVA_DB.Where("app_user_id = ?", user.ID).First(&asset).Error
var act appModel.AppUserActivity
_ = global.GVA_DB.Where("app_user_id = ?", user.ID).First(&act).Error
windowMin := systemSvc.GetAppOnlineWindowMinutes()
now := time.Now()
isOnline := false
if act.LastActiveAt != nil && windowMin > 0 {
isOnline = now.Sub(*act.LastActiveAt) <= time.Duration(windowMin)*time.Minute
}
var recentLoginLogs []appModel.AppLoginLog
_ = global.GVA_DB.Where("app_user_id = ?", user.ID).Order("id desc").Limit(20).Find(&recentLoginLogs).Error
var recentTx []appModel.AppAssetTransaction
_ = global.GVA_DB.Where("app_user_id = ?", user.ID).Order("id desc").Limit(20).Find(&recentTx).Error
// invite summary
var invitedCount int64
_ = global.GVA_DB.Model(&appModel.AppUserInviteRelation{}).Where("inviter_user_id = ?", user.ID).Count(&invitedCount).Error
var inviterUserID *uint
var inviterUsername string
{
var rel appModel.AppUserInviteRelation
if err := global.GVA_DB.Where("invitee_user_id = ?", user.ID).First(&rel).Error; err == nil {
inviterUserID = &rel.InviterUserID
var inviter appModel.AppUser
_ = global.GVA_DB.Select("id, username").First(&inviter, rel.InviterUserID).Error
inviterUsername = inviter.Username
}
}
hasUnusedCode := false
var unusedLast4 string
var unusedExpiresAt *time.Time
{
var code appModel.AppInviteCode
err := global.GVA_DB.
Where("created_by_user_id = ? AND status = ?", user.ID, appModel.InviteCodeStatusUnused).
Order("id desc").
First(&code).Error
if err == nil {
hasUnusedCode = true
unusedLast4 = code.CodeLast4
unusedExpiresAt = code.ExpiresAt
}
}
var resp appUserOverviewResponse
resp.User.ID = user.ID
resp.User.UUID = user.UUID.String()
resp.User.Username = user.Username
resp.User.Enable = user.Enable
resp.User.NickName = profile.NickName
resp.Asset.AccountBalanceLi = asset.AccountBalanceLi
resp.Asset.GameCoinBalanceLi = asset.GameCoinBalanceLi
resp.Activity.LastLoginAt = act.LastLoginAt
resp.Activity.LastActiveAt = act.LastActiveAt
resp.Activity.LastActiveIP = act.LastActiveIP
resp.Activity.IsOnline = isOnline
resp.RecentLoginLogs = recentLoginLogs
resp.RecentTx = recentTx
resp.Invite.InviterUserID = inviterUserID
resp.Invite.InviterUsername = inviterUsername
resp.Invite.InvitedCount = invitedCount
resp.Invite.HasUnusedCode = hasUnusedCode
resp.Invite.UnusedCodeLast4 = unusedLast4
resp.Invite.UnusedExpiresAt = unusedExpiresAt
response.OkWithDetailed(resp, "获取成功", c)
}
// CreateAppUser
// @Tags AppUsersAdmin
// @Summary 后台手动新增玩家
// @Security ApiKeyAuth
// @Produce application/json
// @Param data body createAppUserRequest true "玩家信息"
// @Success 200 {object} response.Response{msg=string} "创建成功"
// @Router /admin/app-users [post]
func (a *AppUsersApi) CreateAppUser(c *gin.Context) {
var req createAppUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
req.Username = strings.TrimSpace(req.Username)
req.NickName = strings.TrimSpace(req.NickName)
req.WelcomePhrase = strings.TrimSpace(req.WelcomePhrase)
enable := 1
if req.Enable != nil && !*req.Enable {
enable = 2
}
if err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
var existed appModel.AppUser
if err := tx.Where("username = ?", req.Username).First(&existed).Error; err == nil {
return errors.New("用户名已存在")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
user := appModel.AppUser{
UUID: uuid.New(),
Username: req.Username,
Password: utils.BcryptHash(req.Password),
Enable: enable,
}
if err := tx.Create(&user).Error; err != nil {
return err
}
profile := appModel.AppUserProfile{
AppUserID: user.ID,
WelcomePhrase: req.WelcomePhrase,
NickName: firstNonEmpty(req.NickName, req.Username),
}
return tx.Create(&profile).Error
}); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("创建成功", c)
}
// UpdateAppUser
// @Tags AppUsersAdmin
// @Summary 编辑玩家(仅启用状态/欢迎词)
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "玩家ID"
// @Param data body updateAppUserRequest true "更新字段"
// @Success 200 {object} response.Response{msg=string} "更新成功"
// @Router /admin/app-users/{id} [patch]
func (a *AppUsersApi) UpdateAppUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var req updateAppUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
var user appModel.AppUser
if err := tx.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("用户不存在")
}
return err
}
if req.Enable != nil {
if *req.Enable {
user.Enable = 1
} else {
user.Enable = 2
}
if err := tx.Model(&user).Select("enable").Updates(&user).Error; err != nil {
return err
}
}
if req.WelcomePhrase != nil {
wp := strings.TrimSpace(*req.WelcomePhrase)
var profile appModel.AppUserProfile
err := tx.Where("app_user_id = ?", user.ID).First(&profile).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
profile = appModel.AppUserProfile{
AppUserID: user.ID,
WelcomePhrase: wp,
NickName: user.Username,
}
return tx.Create(&profile).Error
}
if err != nil {
return err
}
profile.WelcomePhrase = wp
return tx.Model(&profile).Select("welcome_phrase").Updates(&profile).Error
}
return nil
}); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("更新成功", c)
}
// ResetAppUserPassword
// @Tags AppUsersAdmin
// @Summary 重置玩家密码
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "玩家ID"
// @Param data body resetPasswordRequest true "新密码"
// @Success 200 {object} response.Response{msg=string} "重置成功"
// @Router /admin/app-users/{id}/reset-password [post]
func (a *AppUsersApi) ResetAppUserPassword(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var req resetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
var user appModel.AppUser
if err := global.GVA_DB.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.FailWithMessage("用户不存在", c)
return
}
response.FailWithMessage(err.Error(), c)
return
}
if err := global.GVA_DB.Model(&user).Update("password", utils.BcryptHash(req.NewPassword)).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("重置成功", c)
}
// ToggleAppUserEnable
// @Tags AppUsersAdmin
// @Summary 启用/停用玩家
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "玩家ID"
// @Success 200 {object} response.Response{msg=string} "操作成功"
// @Router /admin/app-users/{id}/toggle-enable [post]
func (a *AppUsersApi) ToggleAppUserEnable(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var user appModel.AppUser
if err := global.GVA_DB.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.FailWithMessage("用户不存在", c)
return
}
response.FailWithMessage(err.Error(), c)
return
}
next := 1
if user.Enable == 1 {
next = 2
}
if err := global.GVA_DB.Model(&user).Update("enable", next).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("操作成功", c)
}
// DeleteAppUser
// @Tags AppUsersAdmin
// @Summary 软删除玩家
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "玩家ID"
// @Success 200 {object} response.Response{msg=string} "删除成功"
// @Router /admin/app-users/{id} [delete]
func (a *AppUsersApi) DeleteAppUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var user appModel.AppUser
if err := global.GVA_DB.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.FailWithMessage("用户不存在", c)
return
}
response.FailWithMessage(err.Error(), c)
return
}
if err := global.GVA_DB.Delete(&user).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("删除成功", c)
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}