新增几大中心功能

This commit is contained in:
Administrator
2026-04-23 15:29:07 +08:00
parent 5c2a146a44
commit c6354ee065
123 changed files with 13074 additions and 229 deletions

View File

@@ -0,0 +1,234 @@
package admin
import (
"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"
"github.com/gin-gonic/gin"
)
type AppAssetTransactionsApi struct{}
type appAssetTransactionListItem struct {
ID uint `json:"id"`
AppUserID uint `json:"appUserId"`
Username string `json:"username"`
AssetType string `json:"assetType"`
RechargeType string `json:"rechargeType"`
DeltaLi int64 `json:"deltaLi"`
BeforeLi int64 `json:"beforeLi"`
AfterLi int64 `json:"afterLi"`
Reason string `json:"reason"`
OperatorUserID uint `json:"operatorUserId"`
OperatorName string `json:"operatorName"`
CreatedAt time.Time `json:"createdAt"`
}
// GetAppAssetTransactionsList
// @Tags AppAssetTransactionsAdmin
// @Summary 后台获取玩家资产流水列表(单位:厘)
// @Security ApiKeyAuth
// @Produce application/json
// @Param page query int false "页码" default(1)
// @Param pageSize query int false "每页大小" default(10)
// @Param userId query int false "玩家ID"
// @Param username query string false "用户名(模糊)"
// @Param assetType query string false "资产类型account/game_coin"
// @Param rechargeType query string false "变动类型充值类型offline/online_not_arrived/activity_subsidy/manual_adjustment"
// @Param operatorUserId query int false "操作人ID"
// @Param keyword query string false "关键字(原因模糊)"
// @Param startAt query string false "开始时间(ISO8601/RFC3339)"
// @Param endAt query string false "结束时间(ISO8601/RFC3339)"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
// @Router /admin/app-asset-transactions [get]
func (a *AppAssetTransactionsApi) GetAppAssetTransactionsList(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
}
var (
userID uint
operatorUserID uint
)
if raw := strings.TrimSpace(c.Query("userId")); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
userID = uint(v)
}
}
if raw := strings.TrimSpace(c.Query("operatorUserId")); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
operatorUserID = uint(v)
}
}
username := strings.TrimSpace(c.Query("username"))
assetType := strings.TrimSpace(c.Query("assetType"))
rechargeType := strings.TrimSpace(c.Query("rechargeType"))
keyword := strings.TrimSpace(c.Query("keyword"))
var startAt *time.Time
if raw := strings.TrimSpace(c.Query("startAt")); raw != "" {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
startAt = &t
}
}
var endAt *time.Time
if raw := strings.TrimSpace(c.Query("endAt")); raw != "" {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
endAt = &t
}
}
// tx + join app_users + join sys_users
db := global.GVA_DB.Table("app_asset_transactions tx").
Joins("JOIN app_users u ON u.id = tx.app_user_id").
Joins("LEFT JOIN sys_users su ON su.id = tx.operator_user_id")
if userID > 0 {
db = db.Where("tx.app_user_id = ?", userID)
}
if username != "" {
db = db.Where("u.username ILIKE ?", "%"+username+"%")
}
if assetType != "" {
db = db.Where("tx.asset_type = ?", assetType)
}
if rechargeType != "" {
db = db.Where("tx.recharge_type = ?", rechargeType)
}
if operatorUserID > 0 {
db = db.Where("tx.operator_user_id = ?", operatorUserID)
}
if keyword != "" {
db = db.Where("tx.reason ILIKE ?", "%"+keyword+"%")
}
if startAt != nil {
db = db.Where("tx.created_at >= ?", *startAt)
}
if endAt != nil {
db = db.Where("tx.created_at <= ?", *endAt)
}
var total int64
if err := db.Count(&total).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
type row struct {
appModel.AppAssetTransaction
Username string `gorm:"column:username"`
OperatorName string `gorm:"column:operator_name"`
}
var rows []row
if err := db.Select([]string{
"tx.id",
"tx.app_user_id",
"tx.asset_type",
"tx.recharge_type",
"tx.delta_li",
"tx.before_li",
"tx.after_li",
"tx.reason",
"tx.operator_user_id",
"tx.created_at",
"u.username AS username",
"su.username AS operator_name",
}).Order("tx.id DESC").Limit(pageSize).Offset((page - 1) * pageSize).Scan(&rows).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
items := make([]appAssetTransactionListItem, 0, len(rows))
for _, r := range rows {
items = append(items, appAssetTransactionListItem{
ID: r.ID,
AppUserID: r.AppUserID,
Username: r.Username,
AssetType: string(r.AssetType),
RechargeType: string(r.RechargeType),
DeltaLi: r.DeltaLi,
BeforeLi: r.BeforeLi,
AfterLi: r.AfterLi,
Reason: r.Reason,
OperatorUserID: r.OperatorUserID,
OperatorName: r.OperatorName,
CreatedAt: r.CreatedAt,
})
}
response.OkWithDetailed(response.PageResult{
List: items,
Total: total,
Page: page,
PageSize: pageSize,
}, "获取成功", c)
}
// GetAppAssetTransactionById
// @Tags AppAssetTransactionsAdmin
// @Summary 后台获取玩家资产流水详情
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "流水ID"
// @Success 200 {object} response.Response{data=appAssetTransactionListItem,msg=string} "获取成功"
// @Router /admin/app-asset-transactions/{id} [get]
func (a *AppAssetTransactionsApi) GetAppAssetTransactionById(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
type row struct {
appModel.AppAssetTransaction
Username string `gorm:"column:username"`
OperatorName string `gorm:"column:operator_name"`
}
var r row
err := global.GVA_DB.Table("app_asset_transactions tx").
Joins("JOIN app_users u ON u.id = tx.app_user_id").
Joins("LEFT JOIN sys_users su ON su.id = tx.operator_user_id").
Select([]string{
"tx.id",
"tx.app_user_id",
"tx.asset_type",
"tx.recharge_type",
"tx.delta_li",
"tx.before_li",
"tx.after_li",
"tx.reason",
"tx.operator_user_id",
"tx.created_at",
"u.username AS username",
"su.username AS operator_name",
}).Where("tx.id = ?", id).Scan(&r).Error
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithDetailed(appAssetTransactionListItem{
ID: r.ID,
AppUserID: r.AppUserID,
Username: r.Username,
AssetType: string(r.AssetType),
RechargeType: string(r.RechargeType),
DeltaLi: r.DeltaLi,
BeforeLi: r.BeforeLi,
AfterLi: r.AfterLi,
Reason: r.Reason,
OperatorUserID: r.OperatorUserID,
OperatorName: r.OperatorName,
CreatedAt: r.CreatedAt,
}, "获取成功", c)
}

View File

@@ -0,0 +1,301 @@
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"
appSvc "git.echol.cn/loser/Go-Web-Template/server/service/app"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AppInviteCodesApi struct{}
type appInviteCodeListItem struct {
ID uint `json:"id"`
CreatedByUserID uint `json:"createdByUserId"`
CodeLast4 string `json:"codeLast4"`
Status appModel.InviteCodeStatus `json:"status"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
UsedByUserID *uint `json:"usedByUserId,omitempty"`
UsedAt *time.Time `json:"usedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type appInviteCodeDetailResponse struct {
Code appModel.AppInviteCode `json:"code"`
Inviter struct {
ID uint `json:"id"`
Username string `json:"username"`
} `json:"inviter"`
Invitee *struct {
ID uint `json:"id"`
Username string `json:"username"`
} `json:"invitee,omitempty"`
Relation *appModel.AppUserInviteRelation `json:"relation,omitempty"`
}
type issueInviteCodeRequest struct {
UserID uint `json:"userId" binding:"required"`
ExpireHours *int `json:"expireHours"`
}
type issueInviteCodeResponse struct {
Code string `json:"code"`
CodeLast4 string `json:"codeLast4"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
type clearUnusedRequest struct {
UserID uint `json:"userId" binding:"required"`
Scope string `json:"scope"` // active|all
}
// GetAppInviteCodesList
// @Tags AppInviteCodesAdmin
// @Summary 后台获取玩家邀请码列表
// @Security ApiKeyAuth
// @Produce application/json
// @Param page query int false "页码" default(1)
// @Param pageSize query int false "每页大小" default(10)
// @Param status query string false "状态 unused|used|revoked|expired"
// @Param createdByUserId query int false "邀请人用户ID"
// @Param usedByUserId query int false "使用人用户ID"
// @Param codeLast4 query string false "末4位"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
// @Router /admin/app-invite-codes [get]
func (a *AppInviteCodesApi) GetAppInviteCodesList(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
}
status := strings.TrimSpace(c.Query("status"))
createdByUserID, _ := strconv.Atoi(c.Query("createdByUserId"))
usedByUserID, _ := strconv.Atoi(c.Query("usedByUserId"))
codeLast4 := strings.TrimSpace(c.Query("codeLast4"))
db := global.GVA_DB.Model(&appModel.AppInviteCode{})
if status != "" && status != "expired" {
db = db.Where("status = ?", status)
}
if createdByUserID > 0 {
db = db.Where("created_by_user_id = ?", createdByUserID)
}
if usedByUserID > 0 {
db = db.Where("used_by_user_id = ?", usedByUserID)
}
if codeLast4 != "" {
db = db.Where("code_last4 = ?", codeLast4)
}
// 仅查询“已过期”视图expires_at < now AND status=unused
if status == "expired" {
now := time.Now()
db = db.Where("status = ?", appModel.InviteCodeStatusUnused).Where("expires_at IS NOT NULL AND expires_at <= ?", now)
}
var total int64
if err := db.Count(&total).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
var list []appModel.AppInviteCode
if err := db.Order("id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&list).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
items := make([]appInviteCodeListItem, 0, len(list))
for _, rec := range list {
items = append(items, appInviteCodeListItem{
ID: rec.ID,
CreatedByUserID: rec.CreatedByUserID,
CodeLast4: rec.CodeLast4,
Status: rec.Status,
ExpiresAt: rec.ExpiresAt,
UsedByUserID: rec.UsedByUserID,
UsedAt: rec.UsedAt,
CreatedAt: rec.CreatedAt,
})
}
response.OkWithDetailed(response.PageResult{
List: items,
Total: total,
Page: page,
PageSize: pageSize,
}, "获取成功", c)
}
// GetAppInviteCodeById
// @Tags AppInviteCodesAdmin
// @Summary 后台获取玩家邀请码详情
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "邀请码ID"
// @Success 200 {object} response.Response{data=appInviteCodeDetailResponse,msg=string} "获取成功"
// @Router /admin/app-invite-codes/{id} [get]
func (a *AppInviteCodesApi) GetAppInviteCodeById(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var code appModel.AppInviteCode
if err := global.GVA_DB.First(&code, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.FailWithMessage("记录不存在", c)
return
}
response.FailWithMessage(err.Error(), c)
return
}
var inviter appModel.AppUser
_ = global.GVA_DB.Select("id, username").First(&inviter, code.CreatedByUserID).Error
var relation appModel.AppUserInviteRelation
relErr := global.GVA_DB.Where("invite_code_id = ?", code.ID).First(&relation).Error
var invitee *appModel.AppUser = nil
if relErr == nil {
var u appModel.AppUser
if err := global.GVA_DB.Select("id, username").First(&u, relation.InviteeUserID).Error; err == nil {
invitee = &u
}
}
payload := appInviteCodeDetailResponse{Code: code}
payload.Inviter.ID = inviter.ID
payload.Inviter.Username = inviter.Username
if invitee != nil {
payload.Invitee = &struct {
ID uint `json:"id"`
Username string `json:"username"`
}{ID: invitee.ID, Username: invitee.Username}
}
if relErr == nil {
payload.Relation = &relation
}
response.OkWithData(payload, c)
}
// RevokeAppInviteCode
// @Tags AppInviteCodesAdmin
// @Summary 作废玩家邀请码
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "邀请码ID"
// @Success 200 {object} response.Response{msg=string} "作废成功"
// @Router /admin/app-invite-codes/{id}/revoke [post]
func (a *AppInviteCodesApi) RevokeAppInviteCode(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var code appModel.AppInviteCode
if err := global.GVA_DB.First(&code, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.FailWithMessage("记录不存在", c)
return
}
response.FailWithMessage(err.Error(), c)
return
}
if code.Status == appModel.InviteCodeStatusUsed {
response.FailWithMessage("已使用的邀请码不可作废", c)
return
}
if code.Status == appModel.InviteCodeStatusRevoked {
response.OkWithMessage("已作废", c)
return
}
if err := global.GVA_DB.Model(&code).Update("status", appModel.InviteCodeStatusRevoked).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("作废成功", c)
}
// IssueAppInviteCodeForUser
// @Tags AppInviteCodesAdmin
// @Summary 后台代用户生成邀请码(返回明文一次性)
// @Security ApiKeyAuth
// @Produce application/json
// @Param data body issueInviteCodeRequest true "用户ID"
// @Success 200 {object} response.Response{data=issueInviteCodeResponse,msg=string} "生成成功"
// @Router /admin/app-invite-codes/issue [post]
func (a *AppInviteCodesApi) IssueAppInviteCodeForUser(c *gin.Context) {
var req issueInviteCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
// 先强制作废该用户所有 active unused
now := time.Now()
revokeDB := global.GVA_DB.Model(&appModel.AppInviteCode{}).
Where("created_by_user_id = ? AND status = ?", req.UserID, appModel.InviteCodeStatusUnused).
Where(global.GVA_DB.Where("expires_at IS NULL").Or("expires_at > ?", now)).
Update("status", appModel.InviteCodeStatusRevoked)
if revokeDB.Error != nil {
response.FailWithMessage(revokeDB.Error.Error(), c)
return
}
// 复用现有生成逻辑(按系统参数 expireHours若你传了 expireHours本期先忽略后续可扩展为临时覆盖。
res, err := appSvc.AppInviteCodeServiceApp.Generate(req.UserID)
if err != nil {
// 若生成被“已有未使用码”拦住,说明 revoke 未覆盖(例如过期时间为空/逻辑差异),直接返回错误即可。
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithData(issueInviteCodeResponse{Code: res.Code, CodeLast4: res.CodeLast4, ExpiresAt: res.ExpiresAt}, c)
}
// ClearUnusedInviteCodes
// @Tags AppInviteCodesAdmin
// @Summary 强制清空用户未使用邀请码(批量作废)
// @Security ApiKeyAuth
// @Produce application/json
// @Param data body clearUnusedRequest true "用户ID"
// @Success 200 {object} response.Response{msg=string} "清空成功"
// @Router /admin/app-invite-codes/clear-unused [post]
func (a *AppInviteCodesApi) ClearUnusedInviteCodes(c *gin.Context) {
var req clearUnusedRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
scope := strings.TrimSpace(req.Scope)
if scope == "" {
scope = "active"
}
db := global.GVA_DB.Model(&appModel.AppInviteCode{}).
Where("created_by_user_id = ? AND status = ?", req.UserID, appModel.InviteCodeStatusUnused)
if scope == "active" {
now := time.Now()
db = db.Where(global.GVA_DB.Where("expires_at IS NULL").Or("expires_at > ?", now))
}
if err := db.Update("status", appModel.InviteCodeStatusRevoked).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("清空成功", c)
}

View File

@@ -0,0 +1,138 @@
package admin
import (
"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"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AppLoginLogsApi struct{}
type appLoginLogListItem struct {
ID uint `json:"id"`
AppUserID uint `json:"appUserId"`
Username string `json:"username"`
IP string `json:"ip"`
Referer string `json:"referer,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
Status bool `json:"status"`
ErrorMessage string `json:"errorMessage,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type appLoginLogListResponse struct {
List []appLoginLogListItem `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
// GetAppLoginLogsList
// @Tags AppLoginLogsAdmin
// @Summary 后台获取玩家登录日志列表
// @Security ApiKeyAuth
// @Produce application/json
// @Param page query int true "页码"
// @Param pageSize query int true "每页条数"
// @Param username query string false "用户名"
// @Param ip query string false "IP"
// @Param status query bool false "状态true成功 false失败"
// @Param startAt query string false "开始时间(ISO8601/RFC3339)"
// @Param endAt query string false "结束时间(ISO8601/RFC3339)"
// @Success 200 {object} response.Response{data=appLoginLogListResponse,msg=string} "获取成功"
// @Router /admin/app-login-logs [get]
func (a *AppLoginLogsApi) GetAppLoginLogsList(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
}
username := strings.TrimSpace(c.Query("username"))
ip := strings.TrimSpace(c.Query("ip"))
var status *bool
if raw := strings.TrimSpace(c.Query("status")); raw != "" {
v, err := strconv.ParseBool(raw)
if err == nil {
status = &v
}
}
var startAt *time.Time
if raw := strings.TrimSpace(c.Query("startAt")); raw != "" {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
startAt = &t
}
}
var endAt *time.Time
if raw := strings.TrimSpace(c.Query("endAt")); raw != "" {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
endAt = &t
}
}
db := global.GVA_DB.Model(&appModel.AppLoginLog{})
if username != "" {
db = db.Where("username LIKE ?", "%"+username+"%")
}
if ip != "" {
db = db.Where("ip LIKE ?", "%"+ip+"%")
}
if status != nil {
db = db.Where("status = ?", *status)
}
if startAt != nil {
db = db.Where("created_at >= ?", *startAt)
}
if endAt != nil {
db = db.Where("created_at <= ?", *endAt)
}
var total int64
if err := db.Count(&total).Error; err != nil {
response.FailWithMessage(err.Error(), c)
return
}
var rows []appModel.AppLoginLog
if err := db.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
if err == gorm.ErrRecordNotFound {
rows = []appModel.AppLoginLog{}
} else {
response.FailWithMessage(err.Error(), c)
return
}
}
items := make([]appLoginLogListItem, 0, len(rows))
for _, r := range rows {
items = append(items, appLoginLogListItem{
ID: r.ID,
AppUserID: r.AppUserID,
Username: r.Username,
IP: r.IP,
Referer: r.Referer,
UserAgent: r.UserAgent,
Status: r.Status,
ErrorMessage: r.ErrorMessage,
CreatedAt: r.CreatedAt,
})
}
response.OkWithDetailed(appLoginLogListResponse{
List: items,
Total: total,
Page: page,
PageSize: pageSize,
}, "获取成功", c)
}

View File

@@ -0,0 +1,283 @@
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"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AppRiskTagsApi struct{}
// GetAppRiskTagsList
// @Tags AppRiskTagsAdmin
// @Summary 后台获取风险标签列表
// @Security ApiKeyAuth
// @Produce application/json
// @Param page query int false "页码" default(1)
// @Param pageSize query int false "每页大小" default(50)
// @Param keyword query string false "关键字(名称模糊)"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
// @Router /admin/app-risk-tags [get]
func (a *AppRiskTagsApi) GetAppRiskTagsList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 50
}
keyword := strings.TrimSpace(c.Query("keyword"))
db := global.GVA_DB.Model(&appModel.AppRiskTag{})
if keyword != "" {
db = db.Where("name LIKE ?", "%"+keyword+"%")
}
var total int64
if err := db.Count(&total).Error; err != nil {
response.FailWithMessage("获取失败:"+err.Error(), c)
return
}
var list []appModel.AppRiskTag
if err := db.Order("level desc, id desc").Limit(pageSize).Offset((page - 1) * pageSize).Find(&list).Error; err != nil {
response.FailWithMessage("获取失败:"+err.Error(), c)
return
}
response.OkWithData(response.PageResult{List: list, Total: total, Page: page, PageSize: pageSize}, c)
}
type upsertRiskTagRequest struct {
Name string `json:"name" binding:"required"`
Level int `json:"level" binding:"required"`
Color string `json:"color"`
Desc string `json:"desc"`
}
// CreateAppRiskTag
// @Tags AppRiskTagsAdmin
// @Summary 创建风险标签
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param body body upsertRiskTagRequest true "参数"
// @Success 200 {object} response.Response{msg=string} "创建成功"
// @Router /admin/app-risk-tags [post]
func (a *AppRiskTagsApi) CreateAppRiskTag(c *gin.Context) {
var req upsertRiskTagRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("参数错误", c)
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
response.FailWithMessage("name 不能为空", c)
return
}
if req.Level < 1 || req.Level > 5 {
response.FailWithMessage("level 必须在 1-5", c)
return
}
rec := appModel.AppRiskTag{
Name: req.Name,
Level: req.Level,
Color: strings.TrimSpace(req.Color),
Desc: strings.TrimSpace(req.Desc),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := global.GVA_DB.Create(&rec).Error; err != nil {
response.FailWithMessage("创建失败:"+err.Error(), c)
return
}
response.OkWithMessage("创建成功", c)
}
// UpdateAppRiskTag
// @Tags AppRiskTagsAdmin
// @Summary 更新风险标签
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param id path int true "标签ID"
// @Param body body upsertRiskTagRequest true "参数"
// @Success 200 {object} response.Response{msg=string} "更新成功"
// @Router /admin/app-risk-tags/{id} [put]
func (a *AppRiskTagsApi) UpdateAppRiskTag(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var req upsertRiskTagRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("参数错误", c)
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
response.FailWithMessage("name 不能为空", c)
return
}
if req.Level < 1 || req.Level > 5 {
response.FailWithMessage("level 必须在 1-5", c)
return
}
if err := global.GVA_DB.Model(&appModel.AppRiskTag{}).Where("id = ?", uint(id)).Updates(map[string]any{
"name": req.Name,
"level": req.Level,
"color": strings.TrimSpace(req.Color),
"desc": strings.TrimSpace(req.Desc),
"updated_at": time.Now(),
}).Error; err != nil {
response.FailWithMessage("更新失败:"+err.Error(), c)
return
}
response.OkWithMessage("更新成功", c)
}
// DeleteAppRiskTag
// @Tags AppRiskTagsAdmin
// @Summary 删除风险标签
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "标签ID"
// @Success 200 {object} response.Response{msg=string} "删除成功"
// @Router /admin/app-risk-tags/{id} [delete]
func (a *AppRiskTagsApi) DeleteAppRiskTag(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
if err := global.GVA_DB.Delete(&appModel.AppRiskTag{}, uint(id)).Error; err != nil {
response.FailWithMessage("删除失败:"+err.Error(), c)
return
}
response.OkWithMessage("删除成功", c)
}
type userTagRow struct {
appModel.AppUserRiskTag
TagName string `json:"tagName" gorm:"column:tag_name"`
TagLevel int `json:"tagLevel" gorm:"column:tag_level"`
TagColor string `json:"tagColor" gorm:"column:tag_color"`
}
// GetAppUserRiskTags
// @Tags AppRiskTagsAdmin
// @Summary 获取玩家风险标签
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "玩家ID"
// @Success 200 {object} response.Response{data=[]userTagRow,msg=string} "获取成功"
// @Router /admin/app-users/{id}/risk-tags [get]
func (a *AppRiskTagsApi) GetAppUserRiskTags(c *gin.Context) {
userID, _ := strconv.Atoi(c.Param("id"))
if userID <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var list []userTagRow
if err := global.GVA_DB.
Table("app_user_risk_tags").
Joins("LEFT JOIN app_risk_tags t ON t.id = app_user_risk_tags.tag_id").
Select("app_user_risk_tags.*, t.name AS tag_name, t.level AS tag_level, t.color AS tag_color").
Where("app_user_risk_tags.app_user_id = ?", uint(userID)).
Order("app_user_risk_tags.id desc").
Scan(&list).Error; err != nil {
response.FailWithMessage("获取失败:"+err.Error(), c)
return
}
response.OkWithData(list, c)
}
type setUserTagRequest struct {
TagID uint `json:"tagId" binding:"required"`
Remark string `json:"remark"`
}
// AddAppUserRiskTag
// @Tags AppRiskTagsAdmin
// @Summary 给玩家打标
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param id path int true "玩家ID"
// @Param body body setUserTagRequest true "参数"
// @Success 200 {object} response.Response{msg=string} "操作成功"
// @Router /admin/app-users/{id}/risk-tags [post]
func (a *AppRiskTagsApi) AddAppUserRiskTag(c *gin.Context) {
userID, _ := strconv.Atoi(c.Param("id"))
if userID <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var req setUserTagRequest
if err := c.ShouldBindJSON(&req); err != nil || req.TagID == 0 {
response.FailWithMessage("参数错误", c)
return
}
operatorID := utils.GetUserID(c)
if operatorID == 0 {
response.FailWithMessage("未登录", c)
return
}
// ensure tag exists
var tag appModel.AppRiskTag
if err := global.GVA_DB.First(&tag, req.TagID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.FailWithMessage("标签不存在", c)
return
}
response.FailWithMessage("操作失败:"+err.Error(), c)
return
}
rec := appModel.AppUserRiskTag{
AppUserID: uint(userID),
TagID: req.TagID,
OperatorUserID: operatorID,
Remark: strings.TrimSpace(req.Remark),
CreatedAt: time.Now(),
}
if err := global.GVA_DB.Create(&rec).Error; err != nil {
response.FailWithMessage("操作失败:"+err.Error(), c)
return
}
response.OkWithMessage("已打标", c)
}
// RemoveAppUserRiskTag
// @Tags AppRiskTagsAdmin
// @Summary 移除玩家标签
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "关系ID"
// @Success 200 {object} response.Response{msg=string} "操作成功"
// @Router /admin/app-user-risk-tags/{id} [delete]
func (a *AppRiskTagsApi) RemoveAppUserRiskTag(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
if err := global.GVA_DB.Delete(&appModel.AppUserRiskTag{}, uint(id)).Error; err != nil {
response.FailWithMessage("操作失败:"+err.Error(), c)
return
}
response.OkWithMessage("已移除", c)
}

View File

@@ -0,0 +1,704 @@
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 ""
}

View File

@@ -0,0 +1,528 @@
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"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AppWithdrawOrdersApi struct{}
type appWithdrawOrderListItem struct {
ID uint `json:"id"`
AppUserID uint `json:"appUserId"`
Username string `json:"username"`
AmountLi int64 `json:"amountLi"`
FeeLi int64 `json:"feeLi"`
NetLi int64 `json:"netLi"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
OperatorUserID uint `json:"operatorUserId"`
ApprovedBy uint `json:"approvedBy"`
ApprovedAt *time.Time `json:"approvedAt"`
PaidBy uint `json:"paidBy"`
PaidAt *time.Time `json:"paidAt"`
}
// GetAppWithdrawOrdersList
// @Tags AppWithdrawOrdersAdmin
// @Summary 后台获取提现订单列表(单位:厘)
// @Security ApiKeyAuth
// @Produce application/json
// @Param page query int false "页码" default(1)
// @Param pageSize query int false "每页大小" default(10)
// @Param userId query int false "玩家ID"
// @Param username query string false "用户名(模糊)"
// @Param status query string false "状态pending/approved/rejected/paid/failed"
// @Param startAt query string false "开始时间(ISO8601/RFC3339)"
// @Param endAt query string false "结束时间(ISO8601/RFC3339)"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
// @Router /admin/app-withdraw-orders [get]
func (a *AppWithdrawOrdersApi) GetAppWithdrawOrdersList(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
}
var userID uint
if raw := strings.TrimSpace(c.Query("userId")); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
userID = uint(v)
}
}
username := strings.TrimSpace(c.Query("username"))
status := strings.TrimSpace(c.Query("status"))
var startAt *time.Time
if raw := strings.TrimSpace(c.Query("startAt")); raw != "" {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
startAt = &t
}
}
var endAt *time.Time
if raw := strings.TrimSpace(c.Query("endAt")); raw != "" {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
endAt = &t
}
}
db := global.GVA_DB.Model(&appModel.AppWithdrawOrder{})
if userID > 0 {
db = db.Where("app_user_id = ?", userID)
}
if username != "" {
db = db.Where("username LIKE ?", "%"+username+"%")
}
if status != "" {
db = db.Where("status = ?", status)
}
if startAt != nil {
db = db.Where("created_at >= ?", *startAt)
}
if endAt != nil {
db = db.Where("created_at <= ?", *endAt)
}
var total int64
if err := db.Count(&total).Error; err != nil {
response.FailWithMessage("获取失败:"+err.Error(), c)
return
}
var list []appWithdrawOrderListItem
if err := db.
Order("id desc").
Limit(pageSize).
Offset((page - 1) * pageSize).
Select("id, app_user_id, username, amount_li, fee_li, net_li, status, created_at, operator_user_id, approved_by, approved_at, paid_by, paid_at").
Scan(&list).Error; err != nil {
response.FailWithMessage("获取失败:"+err.Error(), c)
return
}
response.OkWithData(response.PageResult{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
}, c)
}
// GetAppWithdrawOrderById
// @Tags AppWithdrawOrdersAdmin
// @Summary 后台获取提现订单详情(单位:厘)
// @Security ApiKeyAuth
// @Produce application/json
// @Param id path int true "订单ID"
// @Success 200 {object} response.Response{data=appModel.AppWithdrawOrder,msg=string} "获取成功"
// @Router /admin/app-withdraw-orders/{id} [get]
func (a *AppWithdrawOrdersApi) GetAppWithdrawOrderById(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
var rec appModel.AppWithdrawOrder
if err := global.GVA_DB.First(&rec, uint(id)).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.FailWithMessage("订单不存在", c)
return
}
response.FailWithMessage("获取失败:"+err.Error(), c)
return
}
response.OkWithData(rec, c)
}
type createWithdrawOrderRequest struct {
AppUserID uint `json:"appUserId" binding:"required"`
AmountLi int64 `json:"amountLi" binding:"required"`
FeeLi int64 `json:"feeLi"`
Channel string `json:"channel"`
PayeeAccount string `json:"payeeAccount"`
PayeeRealName string `json:"payeeRealName"`
ApplyRemark string `json:"applyRemark"`
}
// CreateAppWithdrawOrder
// @Tags AppWithdrawOrdersAdmin
// @Summary 后台创建提现订单(会冻结账户余额)
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param body body createWithdrawOrderRequest true "参数"
// @Success 200 {object} response.Response{msg=string} "创建成功"
// @Router /admin/app-withdraw-orders [post]
func (a *AppWithdrawOrdersApi) CreateAppWithdrawOrder(c *gin.Context) {
var req createWithdrawOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("参数错误", c)
return
}
if req.AmountLi <= 0 {
response.FailWithMessage("amountLi 必须 > 0", c)
return
}
if req.FeeLi < 0 {
response.FailWithMessage("feeLi 不合法", c)
return
}
net := req.AmountLi - req.FeeLi
if net <= 0 {
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, req.AppUserID).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
}
available := asset.AccountBalanceLi - asset.AccountFrozenLi
if available < req.AmountLi {
return errors.New("可提现余额不足")
}
asset.AccountFrozenLi += req.AmountLi
if err := tx.Model(&asset).Select("account_frozen_li").Updates(&asset).Error; err != nil {
return err
}
order := appModel.AppWithdrawOrder{
AppUserID: user.ID,
Username: user.Username,
AmountLi: req.AmountLi,
FeeLi: req.FeeLi,
NetLi: net,
Status: appModel.AppWithdrawOrderStatusPending,
Channel: strings.TrimSpace(req.Channel),
PayeeAccount: strings.TrimSpace(req.PayeeAccount),
PayeeRealName: strings.TrimSpace(req.PayeeRealName),
ApplyRemark: strings.TrimSpace(req.ApplyRemark),
OperatorUserID: operatorID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return tx.Create(&order).Error
}); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("创建成功", c)
}
type auditWithdrawOrderRequest struct {
AuditRemark string `json:"auditRemark"`
RejectReason string `json:"rejectReason"`
}
// ApproveAppWithdrawOrder
// @Tags AppWithdrawOrdersAdmin
// @Summary 审核通过提现订单
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param id path int true "订单ID"
// @Param body body auditWithdrawOrderRequest false "参数"
// @Success 200 {object} response.Response{msg=string} "操作成功"
// @Router /admin/app-withdraw-orders/{id}/approve [post]
func (a *AppWithdrawOrdersApi) ApproveAppWithdrawOrder(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
operatorID := utils.GetUserID(c)
if operatorID == 0 {
response.FailWithMessage("未登录", c)
return
}
var req auditWithdrawOrderRequest
_ = c.ShouldBindJSON(&req)
now := time.Now()
if err := global.GVA_DB.Model(&appModel.AppWithdrawOrder{}).
Where("id = ? AND status = ?", uint(id), appModel.AppWithdrawOrderStatusPending).
Updates(map[string]any{
"status": appModel.AppWithdrawOrderStatusApproved,
"audit_remark": strings.TrimSpace(req.AuditRemark),
"approved_by": operatorID,
"approved_at": &now,
"operator_user_id": operatorID,
}).Error; err != nil {
response.FailWithMessage("操作失败:"+err.Error(), c)
return
}
response.OkWithMessage("已审核通过", c)
}
// RejectAppWithdrawOrder
// @Tags AppWithdrawOrdersAdmin
// @Summary 审核拒绝提现订单(会解冻)
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param id path int true "订单ID"
// @Param body body auditWithdrawOrderRequest true "参数"
// @Success 200 {object} response.Response{msg=string} "操作成功"
// @Router /admin/app-withdraw-orders/{id}/reject [post]
func (a *AppWithdrawOrdersApi) RejectAppWithdrawOrder(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
operatorID := utils.GetUserID(c)
if operatorID == 0 {
response.FailWithMessage("未登录", c)
return
}
var req auditWithdrawOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("参数错误", c)
return
}
reason := strings.TrimSpace(req.RejectReason)
if reason == "" {
response.FailWithMessage("rejectReason 不能为空", c)
return
}
if err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
var order appModel.AppWithdrawOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, uint(id)).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("订单不存在")
}
return err
}
if order.Status != appModel.AppWithdrawOrderStatusPending {
return errors.New("当前状态不可拒绝")
}
var asset appModel.AppUserAsset
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("app_user_id = ?", order.AppUserID).First(&asset).Error; err != nil {
return err
}
if asset.AccountFrozenLi < order.AmountLi {
return errors.New("冻结余额异常")
}
asset.AccountFrozenLi -= order.AmountLi
if err := tx.Model(&asset).Select("account_frozen_li").Updates(&asset).Error; err != nil {
return err
}
now := time.Now()
return tx.Model(&order).Updates(map[string]any{
"status": appModel.AppWithdrawOrderStatusRejected,
"reject_reason": reason,
"audit_remark": strings.TrimSpace(req.AuditRemark),
"approved_by": operatorID,
"approved_at": &now,
"operator_user_id": operatorID,
}).Error
}); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("已拒绝并解冻", c)
}
type payWithdrawOrderRequest struct {
ExternalTxID string `json:"externalTxId"`
FailureReason string `json:"failureReason"`
}
// MarkPaidAppWithdrawOrder
// @Tags AppWithdrawOrdersAdmin
// @Summary 确认已打款(会扣减余额并解冻)
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param id path int true "订单ID"
// @Param body body payWithdrawOrderRequest false "参数"
// @Success 200 {object} response.Response{msg=string} "操作成功"
// @Router /admin/app-withdraw-orders/{id}/mark-paid [post]
func (a *AppWithdrawOrdersApi) MarkPaidAppWithdrawOrder(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
operatorID := utils.GetUserID(c)
if operatorID == 0 {
response.FailWithMessage("未登录", c)
return
}
var req payWithdrawOrderRequest
_ = c.ShouldBindJSON(&req)
if err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
var order appModel.AppWithdrawOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, uint(id)).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("订单不存在")
}
return err
}
if order.Status != appModel.AppWithdrawOrderStatusApproved {
return errors.New("当前状态不可打款确认")
}
var asset appModel.AppUserAsset
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("app_user_id = ?", order.AppUserID).First(&asset).Error; err != nil {
return err
}
if asset.AccountFrozenLi < order.AmountLi {
return errors.New("冻结余额异常")
}
before := asset.AccountBalanceLi
if before < order.AmountLi {
return errors.New("账户余额不足")
}
asset.AccountFrozenLi -= order.AmountLi
asset.AccountBalanceLi -= order.AmountLi
if err := tx.Model(&asset).Select("account_frozen_li", "account_balance_li").Updates(&asset).Error; err != nil {
return err
}
after := asset.AccountBalanceLi
txRec := appModel.AppAssetTransaction{
AppUserID: order.AppUserID,
AssetType: appModel.AppAssetTypeAccount,
RechargeType: appModel.AppRechargeType("withdraw_paid"),
DeltaLi: -order.AmountLi,
BeforeLi: before,
AfterLi: after,
Reason: "提现打款(订单#" + strconv.Itoa(int(order.ID)) + "",
OperatorUserID: operatorID,
CreatedAt: time.Now(),
}
if err := tx.Create(&txRec).Error; err != nil {
return err
}
now := time.Now()
return tx.Model(&order).Updates(map[string]any{
"status": appModel.AppWithdrawOrderStatusPaid,
"paid_by": operatorID,
"paid_at": &now,
"external_tx_id": strings.TrimSpace(req.ExternalTxID),
"operator_user_id": operatorID,
}).Error
}); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("已确认打款", c)
}
// MarkFailedAppWithdrawOrder
// @Tags AppWithdrawOrdersAdmin
// @Summary 标记打款失败(会解冻)
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param id path int true "订单ID"
// @Param body body payWithdrawOrderRequest true "参数"
// @Success 200 {object} response.Response{msg=string} "操作成功"
// @Router /admin/app-withdraw-orders/{id}/mark-failed [post]
func (a *AppWithdrawOrdersApi) MarkFailedAppWithdrawOrder(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
response.FailWithMessage("参数错误", c)
return
}
operatorID := utils.GetUserID(c)
if operatorID == 0 {
response.FailWithMessage("未登录", c)
return
}
var req payWithdrawOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("参数错误", c)
return
}
reason := strings.TrimSpace(req.FailureReason)
if reason == "" {
response.FailWithMessage("failureReason 不能为空", c)
return
}
if err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
var order appModel.AppWithdrawOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, uint(id)).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("订单不存在")
}
return err
}
if order.Status != appModel.AppWithdrawOrderStatusApproved {
return errors.New("当前状态不可标记失败")
}
var asset appModel.AppUserAsset
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("app_user_id = ?", order.AppUserID).First(&asset).Error; err != nil {
return err
}
if asset.AccountFrozenLi < order.AmountLi {
return errors.New("冻结余额异常")
}
asset.AccountFrozenLi -= order.AmountLi
if err := tx.Model(&asset).Select("account_frozen_li").Updates(&asset).Error; err != nil {
return err
}
return tx.Model(&order).Updates(map[string]any{
"status": appModel.AppWithdrawOrderStatusFailed,
"failure_reason": reason,
"external_tx_id": strings.TrimSpace(req.ExternalTxID),
"operator_user_id": operatorID,
}).Error
}); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("已标记失败并解冻", c)
}

View File

@@ -0,0 +1,22 @@
package admin
import (
"git.echol.cn/loser/Go-Web-Template/server/model/admin"
"git.echol.cn/loser/Go-Web-Template/server/model/common/response"
"github.com/gin-gonic/gin"
)
type DashboardApi struct{}
// GetDashboardStats 运营仪表盘聚合统计(占位实现,避免前端 404后续可接真实聚合查询
func (a *DashboardApi) GetDashboardStats(c *gin.Context) {
stats := admin.DashboardStats{
Feedback: admin.DashboardFeedbackStats{
PendingItems: []admin.DashboardFeedbackPendingItem{},
},
Creators: admin.DashboardCreatorStats{
TopCreators: []admin.DashboardTopCreator{},
},
}
response.OkWithData(stats, c)
}

View File

@@ -0,0 +1,11 @@
package admin
type ApiGroup struct {
DashboardApi
AppUsersApi
AppInviteCodesApi
AppLoginLogsApi
AppAssetTransactionsApi
AppWithdrawOrdersApi
AppRiskTagsApi
}

View File

@@ -0,0 +1,37 @@
package app
import (
"errors"
"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"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AppAuthApi struct{}
func (a *AppAuthApi) Me(c *gin.Context) {
uid := utils.GetUserID(c)
if uid == 0 {
response.NoAuth("未登录或非法访问,请登录", c)
return
}
var user appModel.AppUser
if err := global.GVA_DB.First(&user, uid).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.NoAuth("用户不存在", c)
return
}
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithData(gin.H{
"id": user.ID,
"uuid": user.UUID,
"username": user.Username,
"enable": user.Enable,
}, c)
}

View File

@@ -0,0 +1,7 @@
package app
type ApiGroup struct {
PublicAuthApi
AppAuthApi
InviteCodeApi
}

View File

@@ -0,0 +1,50 @@
package app
import (
"time"
"git.echol.cn/loser/Go-Web-Template/server/model/common/response"
appSvc "git.echol.cn/loser/Go-Web-Template/server/service/app"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
)
type InviteCodeApi struct{}
type inviteCodeCurrentResponse struct {
HasUnused bool `json:"hasUnused"`
Record interface{} `json:"record,omitempty"`
}
// GenerateInviteCode 玩家生成邀请码(返回明文)
func (a *InviteCodeApi) GenerateInviteCode(c *gin.Context) {
uid := utils.GetUserID(c)
res, err := appSvc.AppInviteCodeServiceApp.Generate(uid)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithData(res, c)
}
// CurrentInviteCode 获取当前未使用邀请码(不返回明文)
func (a *InviteCodeApi) CurrentInviteCode(c *gin.Context) {
uid := utils.GetUserID(c)
rec, err := appSvc.AppInviteCodeServiceApp.GetCurrentUnused(uid)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if rec == nil {
response.OkWithData(inviteCodeCurrentResponse{HasUnused: false}, c)
return
}
payload := map[string]interface{}{
"id": rec.ID,
"codeLast4": rec.CodeLast4,
"status": rec.Status,
"expiresAt": rec.ExpiresAt,
"createdAt": rec.CreatedAt.Format(time.RFC3339),
}
response.OkWithData(inviteCodeCurrentResponse{HasUnused: true, Record: payload}, c)
}

View File

@@ -0,0 +1,122 @@
package app
import (
"strings"
"git.echol.cn/loser/Go-Web-Template/server/global"
appModel "git.echol.cn/loser/Go-Web-Template/server/model/app"
appReq "git.echol.cn/loser/Go-Web-Template/server/model/app/request"
appRes "git.echol.cn/loser/Go-Web-Template/server/model/app/response"
commonResp "git.echol.cn/loser/Go-Web-Template/server/model/common/response"
systemReq "git.echol.cn/loser/Go-Web-Template/server/model/system/request"
appSvc "git.echol.cn/loser/Go-Web-Template/server/service/app"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
"github.com/mojocn/base64Captcha"
)
type PublicAuthApi struct{}
func (a *PublicAuthApi) RegisterStatus(c *gin.Context) {
mode := appRes.RegisterMode(appSvc.GetRegisterMode())
requireCaptcha := appSvc.GetRegisterRequireCaptcha()
expireHours := appSvc.GetInviteExpireHours()
payload := appRes.RegisterStatusResponse{
Mode: mode,
RequireCaptcha: requireCaptcha,
InviteExpireHours: expireHours,
}
if mode == appRes.RegisterModeClosed {
payload.Message = "暂不开放注册"
}
commonResp.OkWithData(payload, c)
}
func (a *PublicAuthApi) Register(c *gin.Context) {
var req appReq.PublicRegister
if err := c.ShouldBindJSON(&req); err != nil {
commonResp.FailWithMessage(err.Error(), c)
return
}
mode := appSvc.GetRegisterMode()
if mode == appSvc.RegisterModeClosed {
commonResp.FailWithMessage("注册未开放", c)
return
}
// 可选验证码(复用 base64Captcha 默认内存 store
if appSvc.GetRegisterRequireCaptcha() {
if req.Captcha == "" || req.CaptchaId == "" || !base64Captcha.DefaultMemStore.Verify(req.CaptchaId, req.Captcha, true) {
commonResp.FailWithMessage("验证码错误", c)
return
}
}
req.Username = strings.TrimSpace(req.Username)
req.WelcomePhrase = strings.TrimSpace(req.WelcomePhrase)
if err := appSvc.AppRegisterServiceApp.Register(req, mode); err != nil {
commonResp.FailWithMessage(err.Error(), c)
return
}
commonResp.OkWithMessage("注册成功", c)
}
func (a *PublicAuthApi) Login(c *gin.Context) {
var req appReq.Login
if err := c.ShouldBindJSON(&req); err != nil {
commonResp.FailWithMessage(err.Error(), c)
return
}
req.Username = strings.TrimSpace(req.Username)
user, err := appSvc.AppAuthServiceApp.Login(req.Username, req.Password)
if err != nil {
// 记录失败登录日志(不阻断业务返回)
_ = global.GVA_DB.Create(&appModel.AppLoginLog{
AppUserID: 0,
Username: req.Username,
IP: c.ClientIP(),
Referer: c.GetHeader("Referer"),
UserAgent: c.GetHeader("User-Agent"),
Status: false,
ErrorMessage: err.Error(),
}).Error
commonResp.FailWithMessage(err.Error(), c)
return
}
// 记录最后登录与活跃
_ = appSvc.AppActivityServiceApp.RecordLogin(user.ID, c.ClientIP())
// 记录成功登录日志(不阻断业务返回)
_ = global.GVA_DB.Create(&appModel.AppLoginLog{
AppUserID: user.ID,
Username: user.Username,
IP: c.ClientIP(),
Referer: c.GetHeader("Referer"),
UserAgent: c.GetHeader("User-Agent"),
Status: true,
}).Error
j := utils.NewJWT()
claims := j.CreateAppClaims(utilsBaseClaimsFromAppUser(user))
token, err := j.CreateToken(claims)
if err != nil {
commonResp.FailWithMessage("获取token失败", c)
return
}
commonResp.OkWithData(gin.H{
"token": token,
}, c)
}
func utilsBaseClaimsFromAppUser(user *appModel.AppUser) systemReq.BaseClaims {
return systemReq.BaseClaims{
UUID: user.UUID,
ID: user.ID,
Username: user.Username,
NickName: user.Username,
AuthorityId: 0,
}
}

View File

@@ -1,6 +1,8 @@
package v1
import (
"git.echol.cn/loser/Go-Web-Template/server/api/v1/admin"
"git.echol.cn/loser/Go-Web-Template/server/api/v1/app"
"git.echol.cn/loser/Go-Web-Template/server/api/v1/common"
"git.echol.cn/loser/Go-Web-Template/server/api/v1/system"
)
@@ -10,4 +12,6 @@ var ApiGroupApp = new(ApiGroup)
type ApiGroup struct {
SystemApiGroup system.ApiGroup
CommonApiGroup common.ApiGroup
AdminApiGroup admin.ApiGroup
AppApiGroup app.ApiGroup
}

View File

@@ -7,6 +7,8 @@ type ApiGroup struct {
JwtApi
BaseApi
SystemApi
PublicAuthApi
InviteCodeApi
CasbinApi
SystemApiApi
AuthorityApi
@@ -41,4 +43,6 @@ var (
sysErrorService = service.ServiceGroupApp.SystemServiceGroup.SysErrorService
loginLogService = service.ServiceGroupApp.SystemServiceGroup.LoginLogService
apiTokenService = service.ServiceGroupApp.SystemServiceGroup.ApiTokenService
inviteCodeService = service.ServiceGroupApp.SystemServiceGroup.InviteCodeService
publicRegisterService = service.ServiceGroupApp.SystemServiceGroup.PublicRegisterService
)

View File

@@ -0,0 +1,63 @@
package system
import (
"time"
"git.echol.cn/loser/Go-Web-Template/server/model/common/response"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
)
type InviteCodeApi struct{}
type inviteCodeCurrentResponse struct {
HasUnused bool `json:"hasUnused"`
Record interface{} `json:"record,omitempty"`
}
// GenerateInviteCode
// @Tags InviteCode
// @Summary 生成邀请码(同一时间仅允许一个未使用码)
// @Security ApiKeyAuth
// @Produce application/json
// @Success 200 {object} response.Response{data=system.InviteCodeGenerated,msg=string} "生成成功"
// @Router /user/invite-code/generate [post]
func (a *InviteCodeApi) GenerateInviteCode(c *gin.Context) {
uid := utils.GetUserID(c)
expireHours := inviteCodeService.InviteExpireHours()
res, err := inviteCodeService.Generate(uid, expireHours)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithData(res, c)
}
// CurrentInviteCode
// @Tags InviteCode
// @Summary 获取当前未使用邀请码(不返回明文)
// @Security ApiKeyAuth
// @Produce application/json
// @Success 200 {object} response.Response{data=inviteCodeCurrentResponse,msg=string} "获取成功"
// @Router /user/invite-code/current [get]
func (a *InviteCodeApi) CurrentInviteCode(c *gin.Context) {
uid := utils.GetUserID(c)
rec, err := inviteCodeService.GetCurrentUnused(uid)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if rec == nil {
response.OkWithData(inviteCodeCurrentResponse{HasUnused: false}, c)
return
}
// 仅返回必要字段(不返回 hash
payload := map[string]interface{}{
"id": rec.ID,
"codeLast4": rec.CodeLast4,
"status": rec.Status,
"expiresAt": rec.ExpiresAt,
"createdAt": rec.CreatedAt.Format(time.RFC3339),
}
response.OkWithData(inviteCodeCurrentResponse{HasUnused: true, Record: payload}, c)
}

View File

@@ -0,0 +1,28 @@
package system
import (
"git.echol.cn/loser/Go-Web-Template/server/model/common/response"
"github.com/gin-gonic/gin"
)
type PublicAuthApi struct{}
// RegisterStatus
// @Tags PublicAuth
// @Summary (废弃)获取注册开放状态
// @Produce application/json
// @Success 200 {object} response.Response{msg=string} "已废弃"
// @Router /public/auth/register/status [get]
func (a *PublicAuthApi) RegisterStatus(c *gin.Context) {
response.FailWithMessage("该接口已废弃,请使用 GET /public/app/register/status", c)
}
// PublicRegister
// @Tags PublicAuth
// @Summary (废弃)用户前端注册
// @Produce application/json
// @Success 200 {object} response.Response{msg=string} "已废弃"
// @Router /public/auth/register [post]
func (a *PublicAuthApi) PublicRegister(c *gin.Context) {
response.FailWithMessage("该接口已废弃,请使用 POST /public/app/register", c)
}

View File

@@ -1,10 +1,14 @@
package system
import (
"strconv"
"strings"
"git.echol.cn/loser/Go-Web-Template/server/global"
"git.echol.cn/loser/Go-Web-Template/server/model/common/response"
"git.echol.cn/loser/Go-Web-Template/server/model/system"
systemReq "git.echol.cn/loser/Go-Web-Template/server/model/system/request"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
@@ -33,6 +37,18 @@ func (sysParamsApi *SysParamsApi) CreateSysParams(c *gin.Context) {
response.FailWithMessage("创建失败:"+err.Error(), c)
return
}
// write audit (best-effort)
operatorID := utils.GetUserID(c)
if operatorID > 0 {
_ = global.GVA_DB.Create(&system.SysParamChangeLog{
ParamID: sysParams.ID,
Key: sysParams.Key,
Action: system.SysParamChangeActionCreate,
OldValue: "",
NewValue: sysParams.Value,
OperatorUserID: operatorID,
}).Error
}
response.OkWithMessage("创建成功", c)
}
@@ -47,12 +63,26 @@ func (sysParamsApi *SysParamsApi) CreateSysParams(c *gin.Context) {
// @Router /sysParams/deleteSysParams [delete]
func (sysParamsApi *SysParamsApi) DeleteSysParams(c *gin.Context) {
ID := c.Query("ID")
// read before delete for audit
var before system.SysParams
_ = global.GVA_DB.Where("id = ?", ID).First(&before).Error
err := sysParamsService.DeleteSysParams(ID)
if err != nil {
global.GVA_LOG.Error("删除失败!", zap.Error(err))
response.FailWithMessage("删除失败:"+err.Error(), c)
return
}
operatorID := utils.GetUserID(c)
if operatorID > 0 && before.ID > 0 {
_ = global.GVA_DB.Create(&system.SysParamChangeLog{
ParamID: before.ID,
Key: before.Key,
Action: system.SysParamChangeActionDelete,
OldValue: before.Value,
NewValue: "",
OperatorUserID: operatorID,
}).Error
}
response.OkWithMessage("删除成功", c)
}
@@ -91,15 +121,97 @@ func (sysParamsApi *SysParamsApi) UpdateSysParams(c *gin.Context) {
response.FailWithMessage(err.Error(), c)
return
}
// read before update for audit
var before system.SysParams
_ = global.GVA_DB.Where("id = ?", sysParams.ID).First(&before).Error
err = sysParamsService.UpdateSysParams(sysParams)
if err != nil {
global.GVA_LOG.Error("更新失败!", zap.Error(err))
response.FailWithMessage("更新失败:"+err.Error(), c)
return
}
operatorID := utils.GetUserID(c)
if operatorID > 0 && before.ID > 0 {
_ = global.GVA_DB.Create(&system.SysParamChangeLog{
ParamID: before.ID,
Key: before.Key,
Action: system.SysParamChangeActionUpdate,
OldValue: before.Value,
NewValue: sysParams.Value,
OperatorUserID: operatorID,
}).Error
}
response.OkWithMessage("更新成功", c)
}
// GetSysParamChangeLogList 分页获取参数变更审计
// @Tags SysParams
// @Summary 分页获取参数变更审计
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param page query int false "页码" default(1)
// @Param pageSize query int false "每页大小" default(10)
// @Param key query string false "key模糊"
// @Param operatorUserId query int false "操作人ID"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
// @Router /sysParams/getChangeLogList [get]
func (sysParamsApi *SysParamsApi) GetSysParamChangeLogList(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
}
key := strings.TrimSpace(c.Query("key"))
var operatorID uint
if raw := strings.TrimSpace(c.Query("operatorUserId")); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
operatorID = uint(v)
}
}
db := global.GVA_DB.Model(&system.SysParamChangeLog{})
if key != "" {
db = db.Where("key LIKE ?", "%"+key+"%")
}
if operatorID > 0 {
db = db.Where("operator_user_id = ?", operatorID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
response.FailWithMessage("获取失败:"+err.Error(), c)
return
}
type row struct {
system.SysParamChangeLog
OperatorName string `json:"operatorName" gorm:"column:operator_name"`
}
var list []row
if err := db.
Joins("LEFT JOIN sys_users su ON su.id = sys_param_change_logs.operator_user_id").
Select("sys_param_change_logs.*, su.username AS operator_name").
Order("sys_param_change_logs.id desc").
Limit(pageSize).
Offset((page - 1) * pageSize).
Scan(&list).Error; err != nil {
response.FailWithMessage("获取失败:"+err.Error(), c)
return
}
response.OkWithDetailed(response.PageResult{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
}, "获取成功", c)
}
// FindSysParams 用id查询参数
// @Tags SysParams
// @Summary 用id查询参数

251
server/config.debug.yaml Normal file
View File

@@ -0,0 +1,251 @@
aliyun-oss:
endpoint: yourEndpoint
access-key-id: yourAccessKeyId
access-key-secret: yourAccessKeySecret
bucket-name: yourBucketName
bucket-url: yourBucketUrl
base-path: yourBasePath
aws-s3:
bucket: xxxxx-10005608
region: ap-shanghai
endpoint: ""
secret-id: your-secret-id
secret-key: your-secret-key
base-url: https://gin-react-admin.com
path-prefix: git.echol.cn/loser/Go-Web-Template/server
s3-force-path-style: false
disable-ssl: false
captcha:
key-long: 1
img-width: 240
img-height: 80
open-captcha: 0
open-captcha-timeout: 3600
cloudflare-r2:
bucket: xxxx0bucket
base-url: https://gin-react-admin.com
path: uploads
account-id: xxx_account_id
access-key-id: xxx_key_id
secret-access-key: xxx_secret_key
cors:
mode: strict-whitelist
whitelist:
- allow-origin: example1.com
allow-methods: POST, GET
allow-headers: Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token,X-User-Id
expose-headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type
allow-credentials: true
- allow-origin: example2.com
allow-methods: GET, POST
allow-headers: content-type
expose-headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type
allow-credentials: true
db-list:
- type: ""
alias-name: ""
prefix: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
path: ""
engine: ""
log-mode: ""
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
disable: true
disk-list:
- mount-point: /
email:
to: ""
from: ""
host: ""
secret: ""
nickname: ""
port: 0
is-ssl: false
is-loginauth: false
excel:
dir: ""
hua-wei-obs:
path: you-path
bucket: you-bucket
endpoint: you-endpoint
access-key: you-access-key
secret-key: you-secret-key
jwt:
signing-key: ab1b0a2b-d49c-49c2-8595-c0c9c75a562b
expires-time: 7d
buffer-time: 1d
issuer: qmPlus
local:
path: uploads/file
store-path: uploads/file
base-url: ""
mcp:
name: GVA_MCP
version: v1.0.0
path: ""
addr: 8889
base_url: ""
upstream_base_url: ""
auth_header: ""
request_timeout: 0
sse_path: ""
message_path: ""
url_prefix: ""
separate: false
minio:
endpoint: yourEndpoint
access-key-id: yourAccessKeyId
access-key-secret: yourAccessKeySecret
bucket-name: yourBucketName
use-ssl: false
base-path: ""
bucket-url: http://host:9000/yourBucketName
mongo:
coll: ""
options: ""
database: ""
username: ""
password: ""
auth-source: ""
min-pool-size: 0
max-pool-size: 100
socket-timeout-ms: 0
connect-timeout-ms: 0
is-zap: false
hosts:
- host: ""
port: ""
mssql:
prefix: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
path: ""
engine: ""
log-mode: ""
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
mysql:
prefix: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
path: ""
engine: ""
log-mode: ""
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
oracle:
prefix: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
path: ""
engine: ""
log-mode: ""
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
pgsql:
prefix: ""
port: "35432"
config: sslmode=disable TimeZone=Asia/Shanghai
db-name: go_web_template
username: postgres
password: jjnac7eDy58iaEzP
path: 192.168.201.130
engine: ""
log-mode: error
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
qiniu:
zone: ZoneHuaDong
bucket: ""
img-path: ""
access-key: ""
secret-key: ""
use-https: false
use-cdn-domains: false
redis:
name: sys-cache
addr: 219.152.55.29:6379
password: THBA@6688
db: 8
useCluster: false
clusterAddrs:
- 172.21.0.3:7000
- 172.21.0.4:7001
- 172.21.0.2:7002
redis-list:
- name: app_cache
addr: 219.152.55.29:6379
password: THBA@6688
db: 9
useCluster: false
clusterAddrs:
- 172.21.0.3:7000
- 172.21.0.4:7001
- 172.21.0.2:7002
sqlite:
prefix: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
path: ""
engine: ""
log-mode: ""
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
system:
db-type: pgsql
oss-type: local
router-prefix: ""
addr: 8888
iplimit-count: 15000
iplimit-time: 3600
use-multipoint: false
use-redis: true
use-mongo: false
use-strict-auth: false
disable-auto-migrate: false
tencent-cos:
bucket: xxxxx-10005608
region: ap-shanghai
secret-id: your-secret-id
secret-key: your-secret-key
base-url: https://gin-react-admin.com
path-prefix: git.echol.cn/loser/Go-Web-Template/server
zap:
level: info
prefix: '[git.echol.cn/loser/Go-Web-Template/server]'
format: console
director: log
encode-level: LowercaseColorLevelEncoder
stacktrace-key: stacktrace
show-line: true
log-in-console: true
retention-day: -1

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,166 @@
definitions:
admin.appInviteCodeDetailResponse:
properties:
code:
$ref: '#/definitions/app.AppInviteCode'
invitee:
properties:
id:
type: integer
username:
type: string
type: object
inviter:
properties:
id:
type: integer
username:
type: string
type: object
relation:
$ref: '#/definitions/app.AppUserInviteRelation'
type: object
admin.appUserDetailItem:
properties:
enable:
type: integer
id:
type: integer
nickName:
type: string
username:
type: string
uuid:
type: string
welcomePhrase:
type: string
type: object
admin.clearUnusedRequest:
properties:
scope:
description: active|all
type: string
userId:
type: integer
required:
- userId
type: object
admin.createAppUserRequest:
properties:
enable:
type: boolean
nickName:
type: string
password:
maxLength: 128
minLength: 6
type: string
username:
maxLength: 64
minLength: 3
type: string
welcomePhrase:
type: string
required:
- password
- username
type: object
admin.issueInviteCodeRequest:
properties:
expireHours:
type: integer
userId:
type: integer
required:
- userId
type: object
admin.issueInviteCodeResponse:
properties:
code:
type: string
codeLast4:
type: string
expiresAt:
type: string
type: object
admin.rechargeRequest:
properties:
assetType:
type: string
deltaLi:
type: integer
reason:
type: string
rechargeType:
type: string
required:
- assetType
- deltaLi
- reason
- rechargeType
type: object
admin.resetPasswordRequest:
properties:
newPassword:
maxLength: 128
minLength: 6
type: string
required:
- newPassword
type: object
admin.updateAppUserRequest:
properties:
enable:
type: boolean
welcomePhrase:
type: string
type: object
app.AppInviteCode:
properties:
codeLast4:
type: string
createdAt:
type: string
createdByUserId:
type: integer
expiresAt:
type: string
id:
type: integer
status:
$ref: '#/definitions/app.InviteCodeStatus'
updatedAt:
type: string
usedAt:
type: string
usedByUserId:
type: integer
type: object
app.AppUserInviteRelation:
properties:
createdAt:
type: string
id:
type: integer
inviteCodeId:
type: integer
inviteeUserId:
type: integer
inviterUserId:
type: integer
type: object
app.InviteCodeStatus:
enum:
- unused
- used
- revoked
- expired
type: string
x-enum-varnames:
- InviteCodeStatusUnused
- InviteCodeStatusUsed
- InviteCodeStatusRevoked
- InviteCodeStatusExpired
common.ExaAttachmentCategory:
properties:
ID:
@@ -216,11 +378,6 @@ definitions:
description: 收件人:多个以英文逗号分隔 例a@qq.com b@qq.com 正式开发中请把此项目作为参数使用
type: string
type: object
config.Excel:
properties:
dir:
type: string
type: object
config.HuaWeiObs:
properties:
access-key:
@@ -251,8 +408,11 @@ definitions:
type: object
config.Local:
properties:
base-url:
description: 文件访问域名前缀,如 https://api.wanjia.ai
type: string
path:
description: 本地文件访问路径
description: 本地文件访问路径(相对路径)
type: string
store-path:
description: 本地文件存储路径
@@ -597,8 +757,6 @@ definitions:
type: array
email:
$ref: '#/definitions/config.Email'
excel:
$ref: '#/definitions/config.Excel'
hua-wei-obs:
$ref: '#/definitions/config.HuaWeiObs'
jwt:
@@ -812,6 +970,21 @@ definitions:
description: 栈名
type: string
type: object
git_echol_cn_loser_Go-Web-Template_server_model_system_request.Login:
properties:
captcha:
description: 验证码
type: string
captchaId:
description: 验证码ID
type: string
password:
description: 密码
type: string
username:
description: 用户名
type: string
type: object
request.AddMenuAuthorityInfo:
properties:
authorityId:
@@ -951,21 +1124,6 @@ definitions:
- adminPassword
- dbName
type: object
request.Login:
properties:
captcha:
description: 验证码
type: string
captchaId:
description: 验证码ID
type: string
password:
description: 密码
type: string
username:
description: 用户名
type: string
type: object
request.PageInfo:
properties:
keyword:
@@ -1231,6 +1389,15 @@ definitions:
user:
$ref: '#/definitions/system.SysUser'
type: object
system.InviteCodeGenerated:
properties:
code:
type: string
codeLast4:
type: string
expiresAt:
type: string
type: object
system.Meta:
properties:
activeName:
@@ -1691,18 +1858,410 @@ definitions:
uuid:
description: 用户UUID
type: string
welcomePhrase:
description: 安全欢迎词(用户自定义)
type: string
type: object
system.System:
properties:
config:
$ref: '#/definitions/config.Server'
type: object
system.inviteCodeCurrentResponse:
properties:
hasUnused:
type: boolean
record: {}
type: object
info:
contact: {}
description: 使用gin+react进行极速开发的全栈开发基础平台
title: Gin-React-Admin Swagger API接口文档
version: v2.9.1
paths:
/admin/app-invite-codes:
get:
parameters:
- default: 1
description: 页码
in: query
name: page
type: integer
- default: 10
description: 每页大小
in: query
name: pageSize
type: integer
- description: 状态 unused|used|revoked|expired
in: query
name: status
type: string
- description: 邀请人用户ID
in: query
name: createdByUserId
type: integer
- description: 使用人用户ID
in: query
name: usedByUserId
type: integer
- description: 末4位
in: query
name: codeLast4
type: string
produces:
- application/json
responses:
"200":
description: 获取成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
data:
$ref: '#/definitions/response.PageResult'
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 后台获取玩家邀请码列表
tags:
- AppInviteCodesAdmin
/admin/app-invite-codes/{id}:
get:
parameters:
- description: 邀请码ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 获取成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
data:
$ref: '#/definitions/admin.appInviteCodeDetailResponse'
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 后台获取玩家邀请码详情
tags:
- AppInviteCodesAdmin
/admin/app-invite-codes/{id}/revoke:
post:
parameters:
- description: 邀请码ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 作废成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 作废玩家邀请码
tags:
- AppInviteCodesAdmin
/admin/app-invite-codes/clear-unused:
post:
parameters:
- description: 用户ID
in: body
name: data
required: true
schema:
$ref: '#/definitions/admin.clearUnusedRequest'
produces:
- application/json
responses:
"200":
description: 清空成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 强制清空用户未使用邀请码(批量作废)
tags:
- AppInviteCodesAdmin
/admin/app-invite-codes/issue:
post:
parameters:
- description: 用户ID
in: body
name: data
required: true
schema:
$ref: '#/definitions/admin.issueInviteCodeRequest'
produces:
- application/json
responses:
"200":
description: 生成成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
data:
$ref: '#/definitions/admin.issueInviteCodeResponse'
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 后台代用户生成邀请码(返回明文一次性)
tags:
- AppInviteCodesAdmin
/admin/app-users:
get:
parameters:
- default: 1
description: 页码
in: query
name: page
type: integer
- default: 10
description: 每页大小
in: query
name: pageSize
type: integer
- description: 用户名关键字
in: query
name: keyword
type: string
produces:
- application/json
responses:
"200":
description: 获取成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
data:
$ref: '#/definitions/response.PageResult'
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 后台获取玩家列表
tags:
- AppUsersAdmin
post:
parameters:
- description: 玩家信息
in: body
name: data
required: true
schema:
$ref: '#/definitions/admin.createAppUserRequest'
produces:
- application/json
responses:
"200":
description: 创建成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 后台手动新增玩家
tags:
- AppUsersAdmin
/admin/app-users/{id}:
delete:
parameters:
- description: 玩家ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 删除成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 软删除玩家
tags:
- AppUsersAdmin
get:
parameters:
- description: 玩家ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 获取成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
data:
$ref: '#/definitions/admin.appUserDetailItem'
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 后台获取玩家详情
tags:
- AppUsersAdmin
patch:
parameters:
- description: 玩家ID
in: path
name: id
required: true
type: integer
- description: 更新字段
in: body
name: data
required: true
schema:
$ref: '#/definitions/admin.updateAppUserRequest'
produces:
- application/json
responses:
"200":
description: 更新成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 编辑玩家(仅启用状态/欢迎词)
tags:
- AppUsersAdmin
/admin/app-users/{id}/recharge:
post:
parameters:
- description: 玩家ID
in: path
name: id
required: true
type: integer
- description: 充值信息
in: body
name: data
required: true
schema:
$ref: '#/definitions/admin.rechargeRequest'
produces:
- application/json
responses:
"200":
description: 充值成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 后台手动充值(账户余额/游戏币,单位:厘)
tags:
- AppUsersAdmin
/admin/app-users/{id}/reset-password:
post:
parameters:
- description: 玩家ID
in: path
name: id
required: true
type: integer
- description: 新密码
in: body
name: data
required: true
schema:
$ref: '#/definitions/admin.resetPasswordRequest'
produces:
- application/json
responses:
"200":
description: 重置成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 重置玩家密码
tags:
- AppUsersAdmin
/admin/app-users/{id}/toggle-enable:
post:
parameters:
- description: 玩家ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 操作成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 启用/停用玩家
tags:
- AppUsersAdmin
/api/createApi:
post:
consumes:
@@ -2475,7 +3034,7 @@ paths:
name: data
required: true
schema:
$ref: '#/definitions/request.Login'
$ref: '#/definitions/git_echol_cn_loser_Go-Web-Template_server_model_system_request.Login'
produces:
- application/json
responses:
@@ -3155,6 +3714,40 @@ paths:
summary: 更新菜单
tags:
- Menu
/public/auth/register:
post:
produces:
- application/json
responses:
"200":
description: 已废弃
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
msg:
type: string
type: object
summary: (废弃)用户前端注册
tags:
- PublicAuth
/public/auth/register/status:
get:
produces:
- application/json
responses:
"200":
description: 已废弃
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
msg:
type: string
type: object
summary: (废弃)获取注册开放状态
tags:
- PublicAuth
/sysDictionary/createSysDictionary:
post:
consumes:
@@ -4742,6 +5335,48 @@ paths:
summary: 分页获取用户列表
tags:
- SysUser
/user/invite-code/current:
get:
produces:
- application/json
responses:
"200":
description: 获取成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
data:
$ref: '#/definitions/system.inviteCodeCurrentResponse'
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 获取当前未使用邀请码(不返回明文)
tags:
- InviteCode
/user/invite-code/generate:
post:
produces:
- application/json
responses:
"200":
description: 生成成功
schema:
allOf:
- $ref: '#/definitions/response.Response'
- properties:
data:
$ref: '#/definitions/system.InviteCodeGenerated'
msg:
type: string
type: object
security:
- ApiKeyAuth: []
summary: 生成邀请码(同一时间仅允许一个未使用码)
tags:
- InviteCode
/user/resetPassword:
post:
parameters:

View File

@@ -36,10 +36,16 @@ var (
GVA_Timer timer.Timer = timer.NewTimerTask()
GVA_Concurrency_Control = &singleflight.Group{}
GVA_ROUTERS gin.RoutesInfo
GVA_ACTIVE_DBNAME *string
GVA_MCP_SERVER *server.MCPServer
BlackCache local_cache.Cache
lock sync.RWMutex
// GVA_API_DEFAULT_META 用于在“同步路由”时给新增 API 预填分组与简介
// key: METHOD + " " + PATH (e.g. "GET /admin/app-users")
GVA_API_DEFAULT_META map[string]struct {
ApiGroup string
Description string
}
GVA_ACTIVE_DBNAME *string
GVA_MCP_SERVER *server.MCPServer
BlackCache local_cache.Cache
lock sync.RWMutex
)
// GetGlobalDBByDBName 通过名称获取db list中的db

View File

@@ -48,6 +48,7 @@ func (e *ensureTables) MigrateTable(ctx context.Context) (context.Context, error
sysModel.SysBaseMenuBtn{},
sysModel.SysAuthorityBtn{},
sysModel.SysParams{},
sysModel.SysParamChangeLog{},
sysModel.SysError{},
sysModel.SysLoginLog{},
sysModel.SysApiToken{},

View File

@@ -4,6 +4,7 @@ import (
"os"
"git.echol.cn/loser/Go-Web-Template/server/global"
appModel "git.echol.cn/loser/Go-Web-Template/server/model/app"
commonModel "git.echol.cn/loser/Go-Web-Template/server/model/common"
"git.echol.cn/loser/Go-Web-Template/server/model/system"
@@ -46,6 +47,8 @@ func RegisterTables() {
system.SysApi{},
system.SysIgnoreApi{},
system.SysUser{},
system.InviteCode{},
system.UserInviteRelation{},
system.SysBaseMenu{},
system.JwtBlacklist{},
system.SysAuthority{},
@@ -56,6 +59,7 @@ func RegisterTables() {
system.SysBaseMenuBtn{},
system.SysAuthorityBtn{},
system.SysParams{},
system.SysParamChangeLog{},
system.SysError{},
system.SysApiToken{},
system.SysLoginLog{},
@@ -64,6 +68,18 @@ func RegisterTables() {
commonModel.ExaFileChunk{},
commonModel.ExaFileUploadAndDownload{},
commonModel.ExaAttachmentCategory{},
appModel.AppUser{},
appModel.AppUserProfile{},
appModel.AppInviteCode{},
appModel.AppUserInviteRelation{},
appModel.AppUserAsset{},
appModel.AppUserActivity{},
appModel.AppAssetTransaction{},
appModel.AppLoginLog{},
appModel.AppWithdrawOrder{},
appModel.AppRiskTag{},
appModel.AppUserRiskTag{},
)
if err != nil {
global.GVA_LOG.Error("register table failed", zap.Error(err))

View File

@@ -43,6 +43,7 @@ func Routers() *gin.Engine {
systemRouter := router.RouterGroupApp.System
commonRouter := router.RouterGroupApp.Common
appRouter := router.RouterGroupApp.App
// 如果想要不使用nginx代理前端网页可以修改 web/.env.production 下的
// VUE_APP_BASE_API = /
// VUE_APP_BASE_PATH = http://localhost
@@ -64,9 +65,11 @@ func Routers() *gin.Engine {
// 方便统一添加路由组前缀 多服务器上线使用
PublicGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix)
PrivateGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix)
AdminGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix)
AppGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix)
PrivateGroup.Use(middleware.JWTAuth()).Use(middleware.CasbinHandler())
AdminGroup.Use(middleware.JWTAuth()).Use(middleware.CasbinHandler())
AppGroup.Use(middleware.AppJWTAuth()).Use(middleware.AppActivity())
{
// 健康监测
@@ -75,33 +78,42 @@ func Routers() *gin.Engine {
})
}
{
systemRouter.InitBaseRouter(PublicGroup) // 注册基础功能路由 不做鉴权
systemRouter.InitInitRouter(PublicGroup) // 自动初始化相关
systemRouter.InitBaseRouter(PublicGroup) // 注册基础功能路由 不做鉴权
systemRouter.InitInitRouter(PublicGroup) // 自动初始化相关
systemRouter.InitPublicAuthRouter(PublicGroup) // 用户前端注册相关public
appRouter.InitPublicAuthRouter(PublicGroup) // 玩家端注册/登录public
}
{
systemRouter.InitApiRouter(PrivateGroup, PublicGroup) // 注册功能api路由
systemRouter.InitJwtRouter(PrivateGroup) // jwt相关路由
systemRouter.InitUserRouter(PrivateGroup) // 注册用户路由
systemRouter.InitMenuRouter(PrivateGroup) // 注册menu路由
systemRouter.InitSystemRouter(PrivateGroup) // system相关路由
systemRouter.InitCasbinRouter(PrivateGroup) // 权限相关路由
systemRouter.InitAuthorityRouter(PrivateGroup) // 注册角色路由
systemRouter.InitSysDictionaryRouter(PrivateGroup) // 字典管理
systemRouter.InitSysOperationRecordRouter(PrivateGroup) // 操作记录
systemRouter.InitSysDictionaryDetailRouter(PrivateGroup) // 字典详情管理
systemRouter.InitAuthorityBtnRouterRouter(PrivateGroup) // 按钮权限管理
systemRouter.InitMcpRouter(PrivateGroup) // MCP 管理
systemRouter.InitSysParamsRouter(PrivateGroup, PublicGroup) // 参数管理
systemRouter.InitSysErrorRouter(PrivateGroup, PublicGroup) // 错误日志
systemRouter.InitLoginLogRouter(PrivateGroup) // 登录日志
systemRouter.InitApiTokenRouter(PrivateGroup) // apiToken签发
commonRouter.InitFileUploadAndDownloadRouter(PrivateGroup) // 文件上传下载功能路由
commonRouter.InitAttachmentCategoryRouterRouter(PrivateGroup) // 文件上传下载分类
systemRouter.InitApiRouter(AdminGroup, PublicGroup) // 注册功能api路由
systemRouter.InitJwtRouter(AdminGroup) // jwt相关路由
systemRouter.InitUserRouter(AdminGroup) // 注册用户路由
systemRouter.InitInviteCodeRouter(AdminGroup) // 邀请码(登录态)
systemRouter.InitMenuRouter(AdminGroup) // 注册menu路由
systemRouter.InitSystemRouter(AdminGroup) // system相关路由
systemRouter.InitCasbinRouter(AdminGroup) // 权限相关路由
systemRouter.InitAuthorityRouter(AdminGroup) // 注册角色路由
systemRouter.InitSysDictionaryRouter(AdminGroup) // 字典管理
systemRouter.InitSysOperationRecordRouter(AdminGroup) // 操作记录
systemRouter.InitSysDictionaryDetailRouter(AdminGroup) // 字典详情管理
systemRouter.InitAuthorityBtnRouterRouter(AdminGroup) // 按钮权限管理
systemRouter.InitMcpRouter(AdminGroup) // MCP 管理
systemRouter.InitSysParamsRouter(AdminGroup, PublicGroup) // 参数管理
systemRouter.InitSysErrorRouter(AdminGroup, PublicGroup) // 错误日志
systemRouter.InitLoginLogRouter(AdminGroup) // 登录日志
systemRouter.InitApiTokenRouter(AdminGroup) // apiToken签发
commonRouter.InitFileUploadAndDownloadRouter(AdminGroup) // 文件上传下载功能路由
commonRouter.InitAttachmentCategoryRouterRouter(AdminGroup) // 文件上传下载分类
}
// 玩家端登录态路由(暂与 PrivateGroup 共用 JWT中间件在 jwt-audience todo 中拆分)
{
appRouter.InitAppAuthRouter(AppGroup)
appRouter.InitInviteCodeRouter(AppGroup)
}
// 注册业务路由
initBizRouter(PrivateGroup, PublicGroup)
initBizRouter(AdminGroup, PublicGroup)
global.GVA_ROUTERS = Router.Routes()

View File

@@ -1,6 +1,7 @@
package initialize
import (
v1 "git.echol.cn/loser/Go-Web-Template/server/api/v1"
"git.echol.cn/loser/Go-Web-Template/server/router"
"github.com/gin-gonic/gin"
)
@@ -16,4 +17,42 @@ func initBizRouter(routers ...*gin.RouterGroup) {
holder(publicGroup, privateGroup)
// 管理端扩展接口(需 JWT + Casbin与 PrivateGroup 一致)
privateGroup.GET("/admin/dashboard/stats", v1.ApiGroupApp.AdminApiGroup.DashboardApi.GetDashboardStats)
privateGroup.GET("/admin/app-users", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.GetAppUsersList)
privateGroup.GET("/admin/app-users/:id", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.GetAppUserById)
privateGroup.GET("/admin/app-users/:id/overview", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.GetAppUserOverview)
privateGroup.POST("/admin/app-users", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.CreateAppUser)
privateGroup.PATCH("/admin/app-users/:id", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.UpdateAppUser)
privateGroup.POST("/admin/app-users/:id/reset-password", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.ResetAppUserPassword)
privateGroup.POST("/admin/app-users/:id/toggle-enable", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.ToggleAppUserEnable)
privateGroup.DELETE("/admin/app-users/:id", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.DeleteAppUser)
privateGroup.POST("/admin/app-users/:id/recharge", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.RechargeAppUser)
privateGroup.GET("/admin/app-login-logs", v1.ApiGroupApp.AdminApiGroup.AppLoginLogsApi.GetAppLoginLogsList)
privateGroup.GET("/admin/app-asset-transactions", v1.ApiGroupApp.AdminApiGroup.AppAssetTransactionsApi.GetAppAssetTransactionsList)
privateGroup.GET("/admin/app-asset-transactions/:id", v1.ApiGroupApp.AdminApiGroup.AppAssetTransactionsApi.GetAppAssetTransactionById)
privateGroup.GET("/admin/app-withdraw-orders", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.GetAppWithdrawOrdersList)
privateGroup.GET("/admin/app-withdraw-orders/:id", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.GetAppWithdrawOrderById)
privateGroup.POST("/admin/app-withdraw-orders", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.CreateAppWithdrawOrder)
privateGroup.POST("/admin/app-withdraw-orders/:id/approve", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.ApproveAppWithdrawOrder)
privateGroup.POST("/admin/app-withdraw-orders/:id/reject", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.RejectAppWithdrawOrder)
privateGroup.POST("/admin/app-withdraw-orders/:id/mark-paid", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.MarkPaidAppWithdrawOrder)
privateGroup.POST("/admin/app-withdraw-orders/:id/mark-failed", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.MarkFailedAppWithdrawOrder)
privateGroup.GET("/admin/app-risk-tags", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.GetAppRiskTagsList)
privateGroup.POST("/admin/app-risk-tags", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.CreateAppRiskTag)
privateGroup.PUT("/admin/app-risk-tags/:id", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.UpdateAppRiskTag)
privateGroup.DELETE("/admin/app-risk-tags/:id", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.DeleteAppRiskTag)
privateGroup.GET("/admin/app-users/:id/risk-tags", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.GetAppUserRiskTags)
privateGroup.POST("/admin/app-users/:id/risk-tags", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.AddAppUserRiskTag)
privateGroup.DELETE("/admin/app-user-risk-tags/:id", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.RemoveAppUserRiskTag)
privateGroup.GET("/admin/app-invite-codes", v1.ApiGroupApp.AdminApiGroup.AppInviteCodesApi.GetAppInviteCodesList)
privateGroup.GET("/admin/app-invite-codes/:id", v1.ApiGroupApp.AdminApiGroup.AppInviteCodesApi.GetAppInviteCodeById)
privateGroup.POST("/admin/app-invite-codes/:id/revoke", v1.ApiGroupApp.AdminApiGroup.AppInviteCodesApi.RevokeAppInviteCode)
privateGroup.POST("/admin/app-invite-codes/issue", v1.ApiGroupApp.AdminApiGroup.AppInviteCodesApi.IssueAppInviteCodeForUser)
privateGroup.POST("/admin/app-invite-codes/clear-unused", v1.ApiGroupApp.AdminApiGroup.AppInviteCodesApi.ClearUnusedInviteCodes)
}

View File

@@ -0,0 +1,34 @@
package middleware
import (
"time"
appSvc "git.echol.cn/loser/Go-Web-Template/server/service/app"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
)
// AppActivity 玩家端活跃记录:每次请求写 last_active_at轻量 upsert
func AppActivity() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
uid := utils.GetUserID(c)
if uid == 0 {
return
}
// 避免极端高频写:简单节流(同请求链路内只写一次即可)
if _, exists := c.Get("__app_activity_written"); exists {
return
}
c.Set("__app_activity_written", true)
// 尽量不阻塞响应:允许轻微延迟
go func(userID uint, ip string) {
_ = appSvc.AppActivityServiceApp.TouchActive(userID, ip)
}(uid, c.ClientIP())
_ = time.Now() // 保留 import time避免未来扩展时频繁改动不影响逻辑
}
}

View File

@@ -0,0 +1,74 @@
package middleware
import (
"errors"
"strconv"
"time"
"git.echol.cn/loser/Go-Web-Template/server/global"
"git.echol.cn/loser/Go-Web-Template/server/model/common/response"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// AppJWTAuth 玩家端 JWT 鉴权(仅接受 aud=GVA_APP
func AppJWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := utils.GetToken(c)
if token == "" {
response.NoAuth("未登录或非法访问,请登录", c)
c.Abort()
return
}
if isBlacklist(token) {
response.NoAuth("您的帐户异地登陆或令牌失效", c)
utils.ClearToken(c)
c.Abort()
return
}
j := utils.NewJWT()
claims, err := j.ParseToken(token)
if err != nil {
if errors.Is(err, utils.TokenExpired) {
response.NoAuth("登录已过期,请重新登录", c)
utils.ClearToken(c)
c.Abort()
return
}
response.NoAuth(err.Error(), c)
utils.ClearToken(c)
c.Abort()
return
}
if len(claims.Audience) == 0 || claims.Audience[0] != utils.JWTAudienceApp {
response.NoAuth("令牌无效", c)
utils.ClearToken(c)
c.Abort()
return
}
c.Set("claims", claims)
if claims.ExpiresAt.Unix()-time.Now().Unix() < claims.BufferTime {
dr, _ := utils.ParseDuration(global.GVA_CONFIG.JWT.ExpiresTime)
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(dr))
newToken, _ := j.CreateTokenByOldToken(token, *claims)
newClaims, _ := j.ParseToken(newToken)
c.Header("new-token", newToken)
c.Header("new-expires-at", strconv.FormatInt(newClaims.ExpiresAt.Unix(), 10))
utils.SetToken(c, newToken, int(dr.Seconds()/60))
if global.GVA_CONFIG.System.UseMultipoint {
_ = utils.SetRedisJWT(newToken, newClaims.Username)
}
}
c.Next()
if newToken, exists := c.Get("new-token"); exists {
c.Header("new-token", newToken.(string))
}
if newExpiresAt, exists := c.Get("new-expires-at"); exists {
c.Header("new-expires-at", newExpiresAt.(string))
}
}
}

View File

@@ -18,11 +18,11 @@ func Cors() gin.HandlerFunc {
c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type, New-Token, New-Expires-At")
c.Header("Access-Control-Allow-Credentials", "true")
// 放行所有OPTIONS方法
// 放行所有OPTIONS方法(预检请求不应继续进入路由,否则易落到 404
if method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
// 处理请求
c.Next()
}
}
@@ -50,14 +50,14 @@ func CorsByRules() gin.HandlerFunc {
// 严格白名单模式且未通过检查,直接拒绝处理请求
if whitelist == nil && global.GVA_CONFIG.Cors.Mode == "strict-whitelist" && !(c.Request.Method == "GET" && c.Request.URL.Path == "/health") {
c.AbortWithStatus(http.StatusForbidden)
} else {
// 非严格白名单模式,无论是否通过检查均放行所有 OPTIONS 方法
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
}
return
}
// OPTIONS 预检:直接结束,避免继续匹配路由导致 404
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
// 处理请求
c.Next()
}
}

View File

@@ -44,6 +44,14 @@ func JWTAuth() gin.HandlerFunc {
return
}
// 后台接口仅接受后台受众 token
if len(claims.Audience) == 0 || claims.Audience[0] != utils.JWTAudienceAdmin {
response.NoAuth("令牌无效", c)
utils.ClearToken(c)
c.Abort()
return
}
// 已登录用户被管理员禁用 需要使该用户的jwt失效 此处比较消耗性能 如果需要 请自行打开
// 用户被删除的逻辑 需要优化 此处比较消耗性能 如果需要 请自行打开

View File

@@ -0,0 +1,67 @@
package admin
// DashboardStats 与 web-admin `DashboardStats` 对齐,供 /admin/dashboard/stats 返回。
// 当前为占位实现:各统计为零,列表为空;后续可按业务表聚合替换。
type DashboardStats struct {
Users DashboardUserStats `json:"users"`
Characters DashboardCharacterStats `json:"characters"`
Creators DashboardCreatorStats `json:"creators"`
Feedback DashboardFeedbackStats `json:"feedback"`
System DashboardSystemStats `json:"system"`
}
type DashboardUserStats struct {
Total int `json:"total"`
CreatorCount int `json:"creatorCount"`
OnlineCount int `json:"onlineCount"`
TodayNew int `json:"todayNew"`
WeekNew int `json:"weekNew"`
TotalMessages int `json:"totalMessages"`
TotalChats int `json:"totalChats"`
}
type DashboardCharacterStats struct {
Total int `json:"total"`
Published int `json:"published"`
PendingReview int `json:"pendingReview"`
TodayNew int `json:"todayNew"`
WeekNew int `json:"weekNew"`
}
type DashboardTopCreator struct {
UserId int `json:"userId"`
NickName string `json:"nickName"`
Avatar string `json:"avatar"`
CharCount int `json:"charCount"`
TotalUseCount int `json:"totalUseCount"`
}
type DashboardCreatorStats struct {
Total int `json:"total"`
Contracted int `json:"contracted"`
TodayNew int `json:"todayNew"`
WeekNew int `json:"weekNew"`
PendingApplications int `json:"pendingApplications"`
TopCreators []DashboardTopCreator `json:"topCreators"`
}
type DashboardFeedbackPendingItem struct {
ID int `json:"id"`
Title string `json:"title"`
UserNickname string `json:"userNickname"`
UserAvatar string `json:"userAvatar"`
UnreadCount int `json:"unreadCount"`
CreatedAt string `json:"createdAt"`
}
type DashboardFeedbackStats struct {
Total int `json:"total"`
Open int `json:"open"`
AdminUnread int `json:"adminUnread"`
ClosedToday int `json:"closedToday"`
PendingItems []DashboardFeedbackPendingItem `json:"pendingItems"`
}
type DashboardSystemStats struct {
TotalConversations int `json:"totalConversations"`
}

View File

@@ -0,0 +1,35 @@
package app
import "time"
type AppAssetType string
const (
AppAssetTypeAccount AppAssetType = "account"
AppAssetTypeGameCoin AppAssetType = "game_coin"
)
type AppRechargeType string
const (
AppRechargeTypeOffline AppRechargeType = "offline"
AppRechargeTypeOnlineNotArrived AppRechargeType = "online_not_arrived"
AppRechargeTypeActivitySubsidy AppRechargeType = "activity_subsidy"
AppRechargeTypeManualAdjustment AppRechargeType = "manual_adjustment"
)
// AppAssetTransaction 玩家资产流水(金额单位:厘)
type AppAssetTransaction struct {
ID uint `json:"id" gorm:"primaryKey"`
AppUserID uint `json:"appUserId" gorm:"index;not null"`
AssetType AppAssetType `json:"assetType" gorm:"type:varchar(16);index;not null"`
RechargeType AppRechargeType `json:"rechargeType" gorm:"type:varchar(32);index;not null;default:'manual_adjustment'"`
DeltaLi int64 `json:"deltaLi" gorm:"not null;comment:变动(厘)"`
BeforeLi int64 `json:"beforeLi" gorm:"not null"`
AfterLi int64 `json:"afterLi" gorm:"not null"`
Reason string `json:"reason" gorm:"size:255;not null"`
OperatorUserID uint `json:"operatorUserId" gorm:"index;not null;comment:后台操作人ID"`
CreatedAt time.Time `json:"createdAt"`
}
func (AppAssetTransaction) TableName() string { return "app_asset_transactions" }

View File

@@ -0,0 +1,30 @@
package app
import "time"
type InviteCodeStatus string
const (
InviteCodeStatusUnused InviteCodeStatus = "unused"
InviteCodeStatusUsed InviteCodeStatus = "used"
InviteCodeStatusRevoked InviteCodeStatus = "revoked"
InviteCodeStatusExpired InviteCodeStatus = "expired"
)
// AppInviteCode 玩家邀请码(明文不落库,仅保存 hash
type AppInviteCode struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedByUserID uint `json:"createdByUserId" gorm:"index;not null"`
CodeHash string `json:"-" gorm:"uniqueIndex;size:64;not null"`
CodeLast4 string `json:"codeLast4" gorm:"size:8"`
Status InviteCodeStatus `json:"status" gorm:"type:varchar(16);index;not null"`
ExpiresAt *time.Time `json:"expiresAt" gorm:"index"`
UsedByUserID *uint `json:"usedByUserId" gorm:"index"`
UsedAt *time.Time `json:"usedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AppInviteCode) TableName() string {
return "app_invite_codes"
}

View File

@@ -0,0 +1,18 @@
package app
import "time"
// AppLoginLog 玩家登录日志(用于审计)
type AppLoginLog struct {
ID uint `json:"id" gorm:"primaryKey"`
AppUserID uint `json:"appUserId" gorm:"index;comment:玩家ID(失败时可为0)"`
Username string `json:"username" gorm:"index;size:64;not null;comment:用户名"`
IP string `json:"ip" gorm:"size:64;not null;comment:请求IP"`
Referer string `json:"referer" gorm:"size:2048;comment:参考地址(Referer)"`
UserAgent string `json:"userAgent" gorm:"size:2048;comment:客户端信息(User-Agent)"`
Status bool `json:"status" gorm:"index;not null;comment:登录状态"`
ErrorMessage string `json:"errorMessage" gorm:"size:255;comment:失败原因"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
}
func (AppLoginLog) TableName() string { return "app_login_logs" }

View File

@@ -0,0 +1,19 @@
package app
import "time"
// AppRiskTag 风险标签(人工版)
type AppRiskTag struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:64;uniqueIndex;not null;comment:标签名"`
Level int `json:"level" gorm:"index;not null;default:1;comment:风险级别(1-5)"`
Color string `json:"color" gorm:"size:32;comment:颜色(前端展示)"`
Desc string `json:"desc" gorm:"size:255;comment:说明"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AppRiskTag) TableName() string { return "app_risk_tags" }

View File

@@ -0,0 +1,23 @@
package app
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type AppUser struct {
ID uint `json:"id" gorm:"primaryKey"`
UUID uuid.UUID `json:"uuid" gorm:"index;not null;comment:用户UUID"`
Username string `json:"username" gorm:"uniqueIndex;size:64;not null"`
Password string `json:"-" gorm:"not null"`
Enable int `json:"enable" gorm:"default:1;comment:1正常 2禁用"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AppUser) TableName() string {
return "app_users"
}

View File

@@ -0,0 +1,16 @@
package app
import "time"
// AppUserActivity 玩家登录与活跃信息
type AppUserActivity struct {
ID uint `json:"id" gorm:"primaryKey"`
AppUserID uint `json:"appUserId" gorm:"uniqueIndex;not null"`
LastLoginAt *time.Time `json:"lastLoginAt" gorm:"index"`
LastActiveAt *time.Time `json:"lastActiveAt" gorm:"index"`
LastActiveIP string `json:"lastActiveIp" gorm:"size:64"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AppUserActivity) TableName() string { return "app_user_activity" }

View File

@@ -0,0 +1,16 @@
package app
import "time"
// AppUserAsset 玩家资产(金额单位:厘)
type AppUserAsset struct {
ID uint `json:"id" gorm:"primaryKey"`
AppUserID uint `json:"appUserId" gorm:"uniqueIndex;not null"`
AccountBalanceLi int64 `json:"accountBalanceLi" gorm:"not null;default:0;comment:账户余额(厘)"`
AccountFrozenLi int64 `json:"accountFrozenLi" gorm:"not null;default:0;comment:账户余额冻结(厘)"`
GameCoinBalanceLi int64 `json:"gameCoinBalanceLi" gorm:"not null;default:0;comment:游戏币余额(厘)"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AppUserAsset) TableName() string { return "app_user_assets" }

View File

@@ -0,0 +1,15 @@
package app
import "time"
type AppUserInviteRelation struct {
ID uint `json:"id" gorm:"primaryKey"`
InviterUserID uint `json:"inviterUserId" gorm:"index;not null"`
InviteeUserID uint `json:"inviteeUserId" gorm:"uniqueIndex;not null"`
InviteCodeID uint `json:"inviteCodeId" gorm:"index;not null"`
CreatedAt time.Time `json:"createdAt"`
}
func (AppUserInviteRelation) TableName() string {
return "app_user_invite_relations"
}

View File

@@ -0,0 +1,18 @@
package app
import "time"
type AppUserProfile struct {
ID uint `json:"id" gorm:"primaryKey"`
AppUserID uint `json:"appUserId" gorm:"uniqueIndex;not null"`
WelcomePhrase string `json:"welcomePhrase" gorm:"type:varchar(255);comment:安全欢迎词"`
NickName string `json:"nickName" gorm:"type:varchar(64)"`
Avatar string `json:"avatar" gorm:"type:varchar(255)"`
Channel string `json:"channel" gorm:"type:varchar(64)"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AppUserProfile) TableName() string {
return "app_user_profiles"
}

View File

@@ -0,0 +1,19 @@
package app
import "time"
// AppUserRiskTag 玩家风险标签关系(人工版)
type AppUserRiskTag struct {
ID uint `json:"id" gorm:"primaryKey"`
AppUserID uint `json:"appUserId" gorm:"index;not null"`
TagID uint `json:"tagId" gorm:"index;not null"`
OperatorUserID uint `json:"operatorUserId" gorm:"index;not null;comment:后台操作人ID"`
Remark string `json:"remark" gorm:"size:255;comment:备注"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
}
func (AppUserRiskTag) TableName() string { return "app_user_risk_tags" }

View File

@@ -0,0 +1,48 @@
package app
import "time"
type AppWithdrawOrderStatus string
const (
AppWithdrawOrderStatusPending AppWithdrawOrderStatus = "pending" // 已提交,待审核
AppWithdrawOrderStatusApproved AppWithdrawOrderStatus = "approved" // 已审核通过,待打款
AppWithdrawOrderStatusRejected AppWithdrawOrderStatus = "rejected" // 审核拒绝(已解冻)
AppWithdrawOrderStatusPaid AppWithdrawOrderStatus = "paid" // 已打款(余额已扣减)
AppWithdrawOrderStatusFailed AppWithdrawOrderStatus = "failed" // 打款失败(已解冻)
)
// AppWithdrawOrder 提现订单(金额单位:厘)
type AppWithdrawOrder struct {
ID uint `json:"id" gorm:"primaryKey"`
AppUserID uint `json:"appUserId" gorm:"index;not null;comment:玩家ID"`
Username string `json:"username" gorm:"size:64;index;not null;comment:用户名快照"`
AmountLi int64 `json:"amountLi" gorm:"not null;comment:提现金额(厘)"`
FeeLi int64 `json:"feeLi" gorm:"not null;default:0;comment:手续费(厘)"`
NetLi int64 `json:"netLi" gorm:"not null;comment:实际打款金额(厘)"`
Status AppWithdrawOrderStatus `json:"status" gorm:"type:varchar(16);index;not null;default:'pending'"`
Channel string `json:"channel" gorm:"size:32;comment:打款渠道(预留)"`
PayeeAccount string `json:"payeeAccount" gorm:"size:128;comment:收款账号(脱敏/快照)"`
PayeeRealName string `json:"payeeRealName" gorm:"size:64;comment:收款人(快照)"`
ApplyRemark string `json:"applyRemark" gorm:"size:255;comment:申请备注(预留)"`
AuditRemark string `json:"auditRemark" gorm:"size:255;comment:审核备注"`
RejectReason string `json:"rejectReason" gorm:"size:255;comment:拒绝原因"`
FailureReason string `json:"failureReason" gorm:"size:255;comment:打款失败原因"`
ExternalTxID string `json:"externalTxId" gorm:"size:128;index;comment:外部流水号(预留)"`
OperatorUserID uint `json:"operatorUserId" gorm:"index;not null;comment:创建/最近操作人ID"`
ApprovedBy uint `json:"approvedBy" gorm:"index;comment:审核人ID"`
ApprovedAt *time.Time `json:"approvedAt" gorm:"index"`
PaidBy uint `json:"paidBy" gorm:"index;comment:打款确认人ID"`
PaidAt *time.Time `json:"paidAt" gorm:"index"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AppWithdrawOrder) TableName() string { return "app_withdraw_orders" }

View File

@@ -0,0 +1,8 @@
package request
type Login struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Captcha string `json:"captcha"`
CaptchaId string `json:"captchaId"`
}

View File

@@ -0,0 +1,12 @@
package request
type PublicRegister struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=6,max=128"`
WelcomePhrase string `json:"welcomePhrase" binding:"required,min=1,max=255"`
Captcha string `json:"captcha"`
CaptchaId string `json:"captchaId"`
InviteCode string `json:"inviteCode"`
}

View File

@@ -0,0 +1,16 @@
package response
type RegisterMode string
const (
RegisterModeClosed RegisterMode = "closed"
RegisterModeOpen RegisterMode = "open"
RegisterModeInvite RegisterMode = "invite"
)
type RegisterStatusResponse struct {
Mode RegisterMode `json:"mode"`
RequireCaptcha bool `json:"requireCaptcha"`
InviteExpireHours int `json:"inviteExpireHours,omitempty"`
Message string `json:"message,omitempty"`
}

View File

@@ -0,0 +1,32 @@
package system
import (
"time"
)
type InviteCodeStatus string
const (
InviteCodeStatusUnused InviteCodeStatus = "unused"
InviteCodeStatusUsed InviteCodeStatus = "used"
InviteCodeStatusRevoked InviteCodeStatus = "revoked"
InviteCodeStatusExpired InviteCodeStatus = "expired"
)
// InviteCode 邀请码(明文不落库,仅保存 hash
type InviteCode struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedByUserID uint `json:"createdByUserId" gorm:"index;not null;comment:邀请码生成者用户ID"`
CodeHash string `json:"-" gorm:"uniqueIndex;size:64;not null;comment:邀请码hash(sha256 hex)"`
CodeLast4 string `json:"codeLast4" gorm:"size:8;comment:邀请码末尾4位(展示)"`
Status InviteCodeStatus `json:"status" gorm:"type:varchar(16);index;not null;comment:状态"`
ExpiresAt *time.Time `json:"expiresAt" gorm:"index;comment:过期时间"`
UsedByUserID *uint `json:"usedByUserId" gorm:"index;comment:使用者用户ID"`
UsedAt *time.Time `json:"usedAt" gorm:"comment:使用时间"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (InviteCode) TableName() string {
return "invite_codes"
}

View File

@@ -0,0 +1,15 @@
package request
// PublicRegister 用户前端注册请求public
type PublicRegister struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=6,max=128"`
WelcomePhrase string `json:"welcomePhrase" binding:"required,min=1,max=255"`
// 当 auth.register_require_captcha=1 时必填
Captcha string `json:"captcha"`
CaptchaId string `json:"captchaId"`
// 当 auth.register_mode=invite 时必填
InviteCode string `json:"inviteCode"`
}

View File

@@ -50,13 +50,13 @@ type SetUserAuthorities struct {
}
type ChangeUserInfo struct {
ID uint `gorm:"primarykey"` // 主键ID
NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称
Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号
AuthorityIds []uint `json:"authorityIds" gorm:"-"` // 角色ID
Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱
HeaderImg string `json:"headerImg" gorm:"default:https://qmplusimg.henrongyi.top/gva_header.jpg;comment:用户头像"` // 用户头像
Enable int `json:"enable" gorm:"comment:冻结用户"` //冻结用户
ID uint `gorm:"primarykey"` // 主键ID
NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称
Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号
AuthorityIds []uint `json:"authorityIds" gorm:"-"` // 角色ID
Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱
HeaderImg string `json:"headerImg" gorm:"default:https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y;comment:用户头像"` // 用户头像
Enable int `json:"enable" gorm:"comment:冻结用户"` //冻结用户
Authorities []system.SysAuthority `json:"-" gorm:"many2many:sys_user_authority;"`
}

View File

@@ -0,0 +1,16 @@
package response
type RegisterMode string
const (
RegisterModeClosed RegisterMode = "closed"
RegisterModeOpen RegisterMode = "open"
RegisterModeInvite RegisterMode = "invite"
)
type RegisterStatusResponse struct {
Mode RegisterMode `json:"mode"`
RequireCaptcha bool `json:"requireCaptcha"`
InviteExpireHours int `json:"inviteExpireHours,omitempty"`
Message string `json:"message,omitempty"`
}

View File

@@ -0,0 +1,25 @@
package system
import "time"
type SysParamChangeAction string
const (
SysParamChangeActionCreate SysParamChangeAction = "create"
SysParamChangeActionUpdate SysParamChangeAction = "update"
SysParamChangeActionDelete SysParamChangeAction = "delete"
)
// SysParamChangeLog 参数变更审计(策略/开关等都落在 sys_params
type SysParamChangeLog struct {
ID uint `json:"id" gorm:"primaryKey"`
ParamID uint `json:"paramId" gorm:"index;not null"`
Key string `json:"key" gorm:"index;size:191;not null"`
Action SysParamChangeAction `json:"action" gorm:"type:varchar(16);index;not null"`
OldValue string `json:"oldValue" gorm:"type:text"`
NewValue string `json:"newValue" gorm:"type:text"`
OperatorUserID uint `json:"operatorUserId" gorm:"index;not null"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
}
func (SysParamChangeLog) TableName() string { return "sys_param_change_logs" }

View File

@@ -19,18 +19,19 @@ var _ Login = new(SysUser)
type SysUser struct {
global.GVA_MODEL
UUID uuid.UUID `json:"uuid" gorm:"index;comment:用户UUID"` // 用户UUID
Username string `json:"userName" gorm:"index;comment:用户登录名"` // 用户登录名
Password string `json:"-" gorm:"comment:用户登录密码"` // 用户登录密码
NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称
HeaderImg string `json:"headerImg" gorm:"default:https://qmplusimg.henrongyi.top/gva_header.jpg;comment:用户头像"` // 用户头像
AuthorityId uint `json:"authorityId" gorm:"default:888;comment:用户角色ID"` // 用户角色ID
Authority SysAuthority `json:"authority" gorm:"foreignKey:AuthorityId;references:AuthorityId;comment:用户角色"` // 用户角色
Authorities []SysAuthority `json:"authorities" gorm:"many2many:sys_user_authority;"` // 用户角色
Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号
Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱
Enable int `json:"enable" gorm:"default:1;comment:用户是否被冻结 1正常 2冻结"` //用户是否被冻结 1正常 2冻结
OriginSetting common.JSONMap `json:"originSetting" form:"originSetting" gorm:"type:text;default:null;column:origin_setting;comment:配置;"` //配置
UUID uuid.UUID `json:"uuid" gorm:"index;comment:用户UUID"` // 用户UUID
Username string `json:"userName" gorm:"index;comment:用户登录名"` // 用户登录名
Password string `json:"-" gorm:"comment:用户登录密码"` // 用户登录密码
NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称
HeaderImg string `json:"headerImg" gorm:"default:https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y;comment:用户头像"` // 用户头像
WelcomePhrase string `json:"welcomePhrase" gorm:"type:varchar(255);default:'';comment:欢迎词"` // 欢迎词
AuthorityId uint `json:"authorityId" gorm:"default:888;comment:用户角色ID"` // 用户角色ID
Authority SysAuthority `json:"authority" gorm:"foreignKey:AuthorityId;references:AuthorityId;comment:用户角色"` // 用户角色
Authorities []SysAuthority `json:"authorities" gorm:"many2many:sys_user_authority;"` // 用户角色
Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号
Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱
Enable int `json:"enable" gorm:"default:1;comment:用户是否被冻结 1正常 2冻结"` //用户是否被冻结 1正常 2冻结
OriginSetting common.JSONMap `json:"originSetting" form:"originSetting" gorm:"type:text;default:null;column:origin_setting;comment:配置;"` //配置
}
func (SysUser) TableName() string {

View File

@@ -0,0 +1,16 @@
package system
import "time"
// UserInviteRelation 邀请关系inviter -> invitee
type UserInviteRelation struct {
ID uint `json:"id" gorm:"primaryKey"`
InviterUserID uint `json:"inviterUserId" gorm:"index;not null;comment:邀请人用户ID"`
InviteeUserID uint `json:"inviteeUserId" gorm:"uniqueIndex;not null;comment:被邀请人用户ID(唯一)"`
InviteCodeID uint `json:"inviteCodeId" gorm:"index;not null;comment:邀请码ID"`
CreatedAt time.Time `json:"createdAt"`
}
func (UserInviteRelation) TableName() string {
return "user_invite_relations"
}

View File

@@ -0,0 +1,18 @@
package app
import (
api "git.echol.cn/loser/Go-Web-Template/server/api/v1"
"github.com/gin-gonic/gin"
)
var appAuthApi = api.ApiGroupApp.AppApiGroup.AppAuthApi
type AppAuthRouter struct{}
// InitAppAuthRouter 玩家端登录态接口(/app/*
func (s *AppAuthRouter) InitAppAuthRouter(Router *gin.RouterGroup) {
group := Router.Group("app")
{
group.GET("me", appAuthApi.Me)
}
}

View File

@@ -0,0 +1,7 @@
package app
type RouterGroup struct {
PublicAuthRouter
AppAuthRouter
InviteCodeRouter
}

View File

@@ -0,0 +1,18 @@
package app
import (
api "git.echol.cn/loser/Go-Web-Template/server/api/v1"
"github.com/gin-gonic/gin"
)
var inviteCodeApi = api.ApiGroupApp.AppApiGroup.InviteCodeApi
type InviteCodeRouter struct{}
func (s *InviteCodeRouter) InitInviteCodeRouter(Router *gin.RouterGroup) {
group := Router.Group("app/invite-code")
{
group.POST("generate", inviteCodeApi.GenerateInviteCode)
group.GET("current", inviteCodeApi.CurrentInviteCode)
}
}

View File

@@ -0,0 +1,20 @@
package app
import (
api "git.echol.cn/loser/Go-Web-Template/server/api/v1"
"github.com/gin-gonic/gin"
)
var publicAuthApi = api.ApiGroupApp.AppApiGroup.PublicAuthApi
type PublicAuthRouter struct{}
// InitPublicAuthRouter 玩家端注册/登录public不鉴权
func (s *PublicAuthRouter) InitPublicAuthRouter(PublicRouter *gin.RouterGroup) {
group := PublicRouter.Group("public/app")
{
group.GET("register/status", publicAuthApi.RegisterStatus)
group.POST("register", publicAuthApi.Register)
group.POST("login", publicAuthApi.Login)
}
}

View File

@@ -1,6 +1,7 @@
package router
import (
"git.echol.cn/loser/Go-Web-Template/server/router/app"
"git.echol.cn/loser/Go-Web-Template/server/router/common"
"git.echol.cn/loser/Go-Web-Template/server/router/system"
)
@@ -10,4 +11,5 @@ var RouterGroupApp = new(RouterGroup)
type RouterGroup struct {
System system.RouterGroup
Common common.RouterGroup
App app.RouterGroup
}

View File

@@ -7,9 +7,11 @@ type RouterGroup struct {
JwtRouter
SysRouter
BaseRouter
PublicAuthRouter
InitRouter
MenuRouter
UserRouter
InviteCodeRouter
CasbinRouter
AuthorityRouter
DictionaryRouter
@@ -39,4 +41,6 @@ var (
dictionaryDetailApi = api.ApiGroupApp.SystemApiGroup.DictionaryDetailApi
mcpApi = api.ApiGroupApp.SystemApiGroup.McpApi
sysErrorApi = api.ApiGroupApp.SystemApiGroup.SysErrorApi
publicAuthApi = api.ApiGroupApp.SystemApiGroup.PublicAuthApi
inviteCodeApi = api.ApiGroupApp.SystemApiGroup.InviteCodeApi
)

View File

@@ -0,0 +1,19 @@
package system
import (
"git.echol.cn/loser/Go-Web-Template/server/middleware"
"github.com/gin-gonic/gin"
)
type InviteCodeRouter struct{}
func (s *InviteCodeRouter) InitInviteCodeRouter(Router *gin.RouterGroup) {
group := Router.Group("user/invite-code").Use(middleware.OperationRecord())
{
group.POST("generate", inviteCodeApi.GenerateInviteCode)
}
groupWithoutRecord := Router.Group("user/invite-code")
{
groupWithoutRecord.GET("current", inviteCodeApi.CurrentInviteCode)
}
}

View File

@@ -0,0 +1,14 @@
package system
import "github.com/gin-gonic/gin"
type PublicAuthRouter struct{}
// InitPublicAuthRouter public 注册状态与注册接口(不鉴权)
func (s *PublicAuthRouter) InitPublicAuthRouter(PublicRouter *gin.RouterGroup) {
registerRouter := PublicRouter.Group("public/auth/register")
{
registerRouter.GET("status", publicAuthApi.RegisterStatus)
registerRouter.POST("", publicAuthApi.PublicRegister)
}
}

View File

@@ -18,8 +18,9 @@ func (s *SysParamsRouter) InitSysParamsRouter(Router *gin.RouterGroup, PublicRou
sysParamsRouter.PUT("updateSysParams", sysParamsApi.UpdateSysParams) // 更新参数
}
{
sysParamsRouterWithoutRecord.GET("findSysParams", sysParamsApi.FindSysParams) // 根据ID获取参数
sysParamsRouterWithoutRecord.GET("getSysParamsList", sysParamsApi.GetSysParamsList) // 获取参数列表
sysParamsRouterWithoutRecord.GET("getSysParam", sysParamsApi.GetSysParam) // 根据Key获取参数
sysParamsRouterWithoutRecord.GET("findSysParams", sysParamsApi.FindSysParams) // 根据ID获取参数
sysParamsRouterWithoutRecord.GET("getSysParamsList", sysParamsApi.GetSysParamsList) // 获取参数列表
sysParamsRouterWithoutRecord.GET("getSysParam", sysParamsApi.GetSysParam) // 根据Key获取参数
sysParamsRouterWithoutRecord.GET("getChangeLogList", sysParamsApi.GetSysParamChangeLogList) // 获取参数变更审计
}
}

View File

@@ -0,0 +1,40 @@
package app
import (
"time"
"git.echol.cn/loser/Go-Web-Template/server/global"
appModel "git.echol.cn/loser/Go-Web-Template/server/model/app"
"gorm.io/gorm/clause"
)
type AppActivityService struct{}
var AppActivityServiceApp = new(AppActivityService)
func (s *AppActivityService) TouchActive(userID uint, ip string) error {
now := time.Now()
row := appModel.AppUserActivity{
AppUserID: userID,
LastActiveAt: &now,
LastActiveIP: ip,
}
return global.GVA_DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "app_user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"last_active_at", "last_active_ip", "updated_at"}),
}).Create(&row).Error
}
func (s *AppActivityService) RecordLogin(userID uint, ip string) error {
now := time.Now()
row := appModel.AppUserActivity{
AppUserID: userID,
LastLoginAt: &now,
LastActiveAt: &now,
LastActiveIP: ip,
}
return global.GVA_DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "app_user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"last_login_at", "last_active_at", "last_active_ip", "updated_at"}),
}).Create(&row).Error
}

View File

@@ -0,0 +1,31 @@
package app
import (
"errors"
"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/utils"
"gorm.io/gorm"
)
type AppAuthService struct{}
var AppAuthServiceApp = new(AppAuthService)
func (s *AppAuthService) Login(username, password string) (*appModel.AppUser, error) {
var user appModel.AppUser
if err := global.GVA_DB.Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("用户名或密码错误")
}
return nil, err
}
if user.Enable != 1 {
return nil, errors.New("用户被禁止登录")
}
if !utils.BcryptCheck(password, user.Password) {
return nil, errors.New("用户名或密码错误")
}
return &user, nil
}

View File

@@ -0,0 +1,7 @@
package app
type ServiceGroup struct {
AppAuthService
AppRegisterService
AppInviteCodeService
}

View File

@@ -0,0 +1,132 @@
package app
import (
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/hex"
"errors"
"strings"
"time"
"git.echol.cn/loser/Go-Web-Template/server/global"
appModel "git.echol.cn/loser/Go-Web-Template/server/model/app"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AppInviteCodeService struct{}
var AppInviteCodeServiceApp = new(AppInviteCodeService)
type InviteCodeGenerated struct {
Code string `json:"code"`
CodeLast4 string `json:"codeLast4"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func generateCode() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
enc := base32.StdEncoding.WithPadding(base32.NoPadding)
return strings.ToLower(enc.EncodeToString(buf)), nil
}
func (s *AppInviteCodeService) GetCurrentUnused(userID uint) (*appModel.AppInviteCode, error) {
var record appModel.AppInviteCode
now := time.Now()
err := global.GVA_DB.
Where("created_by_user_id = ? AND status = ?", userID, appModel.InviteCodeStatusUnused).
Where(global.GVA_DB.Where("expires_at IS NULL").Or("expires_at > ?", now)).
Order("id desc").
First(&record).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &record, err
}
func (s *AppInviteCodeService) Generate(userID uint) (*InviteCodeGenerated, error) {
expireHours := GetInviteExpireHours()
if expireHours <= 0 {
expireHours = 24
}
// 单活跃未使用码:存在则拒绝生成
existing, err := s.GetCurrentUnused(userID)
if err != nil {
return nil, err
}
if existing != nil {
return nil, errors.New("已有未使用邀请码")
}
code, err := generateCode()
if err != nil {
return nil, err
}
last4 := ""
if len(code) >= 4 {
last4 = code[len(code)-4:]
}
t := time.Now().Add(time.Duration(expireHours) * time.Hour)
expiresAt := &t
record := appModel.AppInviteCode{
CreatedByUserID: userID,
CodeHash: sha256Hex(code),
CodeLast4: last4,
Status: appModel.InviteCodeStatusUnused,
ExpiresAt: expiresAt,
}
if err := global.GVA_DB.Create(&record).Error; err != nil {
return nil, err
}
return &InviteCodeGenerated{Code: code, CodeLast4: last4, ExpiresAt: expiresAt}, nil
}
func (s *AppInviteCodeService) ConsumeByPlainCode(tx *gorm.DB, code string, inviteeUserID uint) (*appModel.AppInviteCode, error) {
code = strings.TrimSpace(code)
if code == "" {
return nil, errors.New("邀请码不能为空")
}
now := time.Now()
var record appModel.AppInviteCode
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("code_hash = ? AND status = ?", sha256Hex(code), appModel.InviteCodeStatusUnused).
Where(tx.Where("expires_at IS NULL").Or("expires_at > ?", now)).
First(&record).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("邀请码无效")
}
return nil, err
}
usedAt := time.Now()
record.Status = appModel.InviteCodeStatusUsed
record.UsedByUserID = &inviteeUserID
record.UsedAt = &usedAt
if err := tx.Model(&record).Select("status", "used_by_user_id", "used_at").Updates(&record).Error; err != nil {
return nil, err
}
rel := appModel.AppUserInviteRelation{
InviterUserID: record.CreatedByUserID,
InviteeUserID: inviteeUserID,
InviteCodeID: record.ID,
CreatedAt: time.Now(),
}
if err := tx.Create(&rel).Error; err != nil {
return nil, err
}
return &record, nil
}

View File

@@ -0,0 +1,68 @@
package app
import (
"errors"
"strings"
"git.echol.cn/loser/Go-Web-Template/server/global"
appModel "git.echol.cn/loser/Go-Web-Template/server/model/app"
appReq "git.echol.cn/loser/Go-Web-Template/server/model/app/request"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/google/uuid"
"gorm.io/gorm"
)
type AppRegisterService struct{}
var AppRegisterServiceApp = new(AppRegisterService)
func (s *AppRegisterService) Register(req appReq.PublicRegister, mode RegisterMode) error {
username := strings.TrimSpace(req.Username)
password := req.Password
welcome := strings.TrimSpace(req.WelcomePhrase)
if username == "" || password == "" || welcome == "" {
return errors.New("参数不完整")
}
if len(welcome) > 255 {
return errors.New("欢迎词过长")
}
if mode == RegisterModeInvite && strings.TrimSpace(req.InviteCode) == "" {
return errors.New("邀请码不能为空")
}
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
var existed appModel.AppUser
if err := tx.Where("username = ?", 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: username,
Password: utils.BcryptHash(password),
Enable: 1,
}
if err := tx.Create(&user).Error; err != nil {
return err
}
profile := appModel.AppUserProfile{
AppUserID: user.ID,
WelcomePhrase: welcome,
NickName: username,
}
if err := tx.Create(&profile).Error; err != nil {
return err
}
if mode == RegisterModeInvite {
if _, err := AppInviteCodeServiceApp.ConsumeByPlainCode(tx, req.InviteCode, user.ID); err != nil {
return err
}
}
return nil
})
}

View File

@@ -0,0 +1,15 @@
package app
import systemSvc "git.echol.cn/loser/Go-Web-Template/server/service/system"
type RegisterMode = systemSvc.RegisterMode
const (
RegisterModeClosed = systemSvc.RegisterModeClosed
RegisterModeOpen = systemSvc.RegisterModeOpen
RegisterModeInvite = systemSvc.RegisterModeInvite
)
func GetRegisterMode() RegisterMode { return systemSvc.GetRegisterMode() }
func GetRegisterRequireCaptcha() bool { return systemSvc.GetRegisterRequireCaptcha() }
func GetInviteExpireHours() int { return systemSvc.GetInviteExpireHours() }

View File

@@ -1,6 +1,7 @@
package service
import (
"git.echol.cn/loser/Go-Web-Template/server/service/app"
"git.echol.cn/loser/Go-Web-Template/server/service/common"
"git.echol.cn/loser/Go-Web-Template/server/service/system"
)
@@ -10,4 +11,5 @@ var ServiceGroupApp = new(ServiceGroup)
type ServiceGroup struct {
SystemServiceGroup system.ServiceGroup
CommonServiceGroup common.ServiceGroup
AppServiceGroup app.ServiceGroup
}

View File

@@ -0,0 +1,30 @@
package system
import (
"strconv"
"strings"
"git.echol.cn/loser/Go-Web-Template/server/global"
"git.echol.cn/loser/Go-Web-Template/server/model/system"
"gorm.io/gorm"
)
const paramAppOnlineWindowMinutes = "app.online_active_window_minutes"
// GetAppOnlineWindowMinutes 在线判定窗口(分钟),默认 5
func GetAppOnlineWindowMinutes() int {
var rec system.SysParams
err := global.GVA_DB.Where("key = ?", paramAppOnlineWindowMinutes).First(&rec).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return 5
}
return 5
}
v := strings.TrimSpace(rec.Value)
n, e := strconv.Atoi(v)
if e != nil || n <= 0 || n > 1440 {
return 5
}
return n
}

View File

@@ -5,6 +5,8 @@ type ServiceGroup struct {
ApiService
MenuService
UserService
InviteCodeService
PublicRegisterService
CasbinService
InitDBService
BaseMenuService

View File

@@ -0,0 +1,144 @@
package system
import (
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/hex"
"errors"
"strings"
"time"
"git.echol.cn/loser/Go-Web-Template/server/global"
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type InviteCodeService struct{}
var InviteCodeServiceApp = new(InviteCodeService)
type InviteCodeCurrent struct {
HasUnused bool `json:"hasUnused"`
Record *sysModel.InviteCode `json:"record,omitempty"`
}
type InviteCodeGenerated struct {
Code string `json:"code"`
CodeLast4 string `json:"codeLast4"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func generateCode() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
enc := base32.StdEncoding.WithPadding(base32.NoPadding)
return strings.ToLower(enc.EncodeToString(buf)), nil
}
func (s *InviteCodeService) GetCurrentUnused(userID uint) (*sysModel.InviteCode, error) {
var record sysModel.InviteCode
now := time.Now()
err := global.GVA_DB.
Where("created_by_user_id = ? AND status = ?", userID, sysModel.InviteCodeStatusUnused).
Where(global.GVA_DB.Where("expires_at IS NULL").Or("expires_at > ?", now)).
Order("id desc").
First(&record).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &record, err
}
func (s *InviteCodeService) Generate(userID uint, expireHours int) (*InviteCodeGenerated, error) {
if expireHours <= 0 {
expireHours = 24
}
// 单活跃未使用码:存在则拒绝生成(用户已选择该策略)
existing, err := s.GetCurrentUnused(userID)
if err != nil {
return nil, err
}
if existing != nil {
return nil, errors.New("已有未使用邀请码")
}
code, err := generateCode()
if err != nil {
return nil, err
}
last4 := ""
if len(code) >= 4 {
last4 = code[len(code)-4:]
}
var expiresAt *time.Time
if expireHours > 0 {
t := time.Now().Add(time.Duration(expireHours) * time.Hour)
expiresAt = &t
}
record := sysModel.InviteCode{
CreatedByUserID: userID,
CodeHash: sha256Hex(code),
CodeLast4: last4,
Status: sysModel.InviteCodeStatusUnused,
ExpiresAt: expiresAt,
}
if err := global.GVA_DB.Create(&record).Error; err != nil {
return nil, err
}
return &InviteCodeGenerated{Code: code, CodeLast4: last4, ExpiresAt: expiresAt}, nil
}
// ConsumeByPlainCode 校验并消耗邀请码(用于注册邀请码模式,需事务调用)
func (s *InviteCodeService) ConsumeByPlainCode(tx *gorm.DB, code string, inviteeUserID uint) (*sysModel.InviteCode, error) {
code = strings.TrimSpace(code)
if code == "" {
return nil, errors.New("邀请码不能为空")
}
now := time.Now()
var record sysModel.InviteCode
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("code_hash = ? AND status = ?", sha256Hex(code), sysModel.InviteCodeStatusUnused).
Where(tx.Where("expires_at IS NULL").Or("expires_at > ?", now)).
First(&record).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("邀请码无效")
}
return nil, err
}
usedAt := time.Now()
record.Status = sysModel.InviteCodeStatusUsed
record.UsedByUserID = &inviteeUserID
record.UsedAt = &usedAt
if err := tx.Model(&record).Select("status", "used_by_user_id", "used_at").Updates(&record).Error; err != nil {
return nil, err
}
// 写邀请关系invitee 唯一)
rel := sysModel.UserInviteRelation{
InviterUserID: record.CreatedByUserID,
InviteeUserID: inviteeUserID,
InviteCodeID: record.ID,
CreatedAt: time.Now(),
}
if err := tx.Create(&rel).Error; err != nil {
return nil, err
}
return &record, nil
}

View File

@@ -0,0 +1,70 @@
package system
import (
"errors"
"strings"
"git.echol.cn/loser/Go-Web-Template/server/global"
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
systemReq "git.echol.cn/loser/Go-Web-Template/server/model/system/request"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
type PublicRegisterService struct{}
var PublicRegisterServiceApp = new(PublicRegisterService)
func (s *PublicRegisterService) Register(_ *gin.Context, req systemReq.PublicRegister, mode RegisterMode) error {
username := strings.TrimSpace(req.Username)
password := req.Password
welcome := strings.TrimSpace(req.WelcomePhrase)
if username == "" || password == "" || welcome == "" {
return errors.New("参数不完整")
}
if len(welcome) > 255 {
return errors.New("欢迎词过长")
}
if mode == RegisterModeInvite && strings.TrimSpace(req.InviteCode) == "" {
return errors.New("邀请码不能为空")
}
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
// 用户名唯一校验
var existed sysModel.SysUser
if err := tx.Where("username = ?", username).First(&existed).Error; err == nil {
return errors.New("用户名已注册")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
user := sysModel.SysUser{
UUID: uuid.New(),
Username: username,
Password: utils.BcryptHash(password),
NickName: username,
WelcomePhrase: welcome,
AuthorityId: 9528, // 用户已选择:注册用户默认绑定现有 9528 角色
Enable: 1,
}
if err := tx.Create(&user).Error; err != nil {
return err
}
if mode == RegisterModeInvite {
if _, err := InviteCodeServiceApp.ConsumeByPlainCode(tx, req.InviteCode, user.ID); err != nil {
return err
}
}
// Authorities 关联:保持与原系统一致,给用户绑定其 AuthorityId 对应角色
if err := tx.Model(&user).Association("Authorities").Replace([]sysModel.SysAuthority{{AuthorityId: user.AuthorityId}}); err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,84 @@
package system
import (
"strconv"
"git.echol.cn/loser/Go-Web-Template/server/global"
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
)
const (
paramRegisterMode = "auth.register_mode"
paramRegisterRequireCaptcha = "auth.register_require_captcha"
paramInviteExpireHours = "auth.invite_code_expire_hours"
)
type RegisterMode string
const (
RegisterModeClosed RegisterMode = "closed"
RegisterModeOpen RegisterMode = "open"
RegisterModeInvite RegisterMode = "invite"
)
var allowedInviteExpireHours = map[int]bool{
12: true,
24: true,
72: true,
168: true,
720: true,
}
func getSysParamValue(key string) (string, bool) {
var param sysModel.SysParams
if err := global.GVA_DB.Where(sysModel.SysParams{Key: key}).First(&param).Error; err != nil {
return "", false
}
return param.Value, true
}
func GetRegisterMode() RegisterMode {
if v, ok := getSysParamValue(paramRegisterMode); ok {
switch RegisterMode(v) {
case RegisterModeClosed, RegisterModeOpen, RegisterModeInvite:
return RegisterMode(v)
}
}
return RegisterModeClosed
}
func GetRegisterRequireCaptcha() bool {
if v, ok := getSysParamValue(paramRegisterRequireCaptcha); ok {
return v == "1" || v == "true" || v == "on"
}
return false
}
func GetInviteExpireHours() int {
if v, ok := getSysParamValue(paramInviteExpireHours); ok {
if n, err := strconv.Atoi(v); err == nil {
if allowedInviteExpireHours[n] {
return n
}
}
}
return 24
}
// ---- 供 API 层调用的实例方法(避免跨包直接引用自由函数) ----
func (s *PublicRegisterService) RegisterMode() RegisterMode {
return GetRegisterMode()
}
func (s *PublicRegisterService) RequireCaptcha() bool {
return GetRegisterRequireCaptcha()
}
func (s *PublicRegisterService) InviteExpireHours() int {
return GetInviteExpireHours()
}
func (s *InviteCodeService) InviteExpireHours() int {
return GetInviteExpireHours()
}

View File

@@ -102,10 +102,20 @@ func (apiService *ApiService) SyncApi() (newApis, deleteApis, ignoreApis []syste
}
}
if !flag {
// prefill ApiGroup/Description from default meta map if available
apiGroup := ""
desc := ""
if global.GVA_API_DEFAULT_META != nil {
k := strings.ToUpper(strings.TrimSpace(cacheApis[i].Method)) + " " + strings.TrimSpace(cacheApis[i].Path)
if m, ok := global.GVA_API_DEFAULT_META[k]; ok {
apiGroup = m.ApiGroup
desc = m.Description
}
}
newApis = append(newApis, system.SysApi{
Path: cacheApis[i].Path,
Description: "",
ApiGroup: "",
Description: desc,
ApiGroup: apiGroup,
Method: cacheApis[i].Method,
})
}

View File

@@ -2,6 +2,7 @@ package common
import (
"context"
commonModel "git.echol.cn/loser/Go-Web-Template/server/model/common"
"git.echol.cn/loser/Go-Web-Template/server/service/system"
"github.com/pkg/errors"

View File

@@ -2,7 +2,9 @@ package system
import (
"context"
"strings"
"git.echol.cn/loser/Go-Web-Template/server/global"
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
"git.echol.cn/loser/Go-Web-Template/server/service/system"
"github.com/pkg/errors"
@@ -13,9 +15,81 @@ type initApi struct{}
const initOrderApi = system.InitOrderSystem + 1
func registerApiDefaultMeta(entities []sysModel.SysApi) {
if global.GVA_API_DEFAULT_META == nil {
global.GVA_API_DEFAULT_META = make(map[string]struct {
ApiGroup string
Description string
}, len(entities))
}
for _, e := range entities {
key := strings.ToUpper(strings.TrimSpace(e.Method)) + " " + strings.TrimSpace(e.Path)
if _, ok := global.GVA_API_DEFAULT_META[key]; !ok {
global.GVA_API_DEFAULT_META[key] = struct {
ApiGroup string
Description string
}{ApiGroup: e.ApiGroup, Description: e.Description}
}
}
}
// auto run
func init() {
system.RegisterInit(initOrderApi, &initApi{})
// ensure default meta available even when db seed is skipped
if global.GVA_API_DEFAULT_META == nil {
global.GVA_API_DEFAULT_META = make(map[string]struct {
ApiGroup string
Description string
})
}
// 关键:即使跳过 SysApi 灌库,也要能在“同步路由”时预填分组与简介
// 这里只注册“我们新增/扩展过的接口”以及玩家端相关接口,避免 UI 再次手填。
registerApiDefaultMeta([]sysModel.SysApi{
{ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getChangeLogList", Description: "获取参数变更审计"},
{ApiGroup: "玩家管理", Method: "GET", Path: "/admin/app-users/:id", Description: "获取玩家详情"},
{ApiGroup: "玩家管理", Method: "GET", Path: "/admin/app-users/:id/overview", Description: "获取玩家聚合概览"},
{ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/reset-password", Description: "重置玩家密码"},
{ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/toggle-enable", Description: "启用/停用玩家"},
{ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/recharge", Description: "后台手动充值(账户余额/游戏币)"},
{ApiGroup: "玩家管理", Method: "DELETE", Path: "/admin/app-users/:id", Description: "软删除玩家"},
{ApiGroup: "玩家管理", Method: "PATCH", Path: "/admin/app-users/:id", Description: "编辑玩家(仅启用状态/欢迎词)"},
{ApiGroup: "玩家资产流水", Method: "GET", Path: "/admin/app-asset-transactions", Description: "获取玩家资产流水列表"},
{ApiGroup: "玩家资产流水", Method: "GET", Path: "/admin/app-asset-transactions/:id", Description: "获取玩家资产流水详情"},
{ApiGroup: "提现管理", Method: "GET", Path: "/admin/app-withdraw-orders", Description: "获取提现订单列表"},
{ApiGroup: "提现管理", Method: "GET", Path: "/admin/app-withdraw-orders/:id", Description: "获取提现订单详情"},
{ApiGroup: "提现管理", Method: "POST", Path: "/admin/app-withdraw-orders", Description: "后台创建提现订单(冻结账户余额)"},
{ApiGroup: "提现管理", Method: "POST", Path: "/admin/app-withdraw-orders/:id/approve", Description: "审核通过提现订单"},
{ApiGroup: "提现管理", Method: "POST", Path: "/admin/app-withdraw-orders/:id/reject", Description: "审核拒绝提现订单(解冻)"},
{ApiGroup: "提现管理", Method: "POST", Path: "/admin/app-withdraw-orders/:id/mark-paid", Description: "确认已打款(扣减余额并解冻)"},
{ApiGroup: "提现管理", Method: "POST", Path: "/admin/app-withdraw-orders/:id/mark-failed", Description: "标记打款失败(解冻)"},
{ApiGroup: "风控标签", Method: "GET", Path: "/admin/app-risk-tags", Description: "获取风险标签列表"},
{ApiGroup: "风控标签", Method: "POST", Path: "/admin/app-risk-tags", Description: "创建风险标签"},
{ApiGroup: "风控标签", Method: "PUT", Path: "/admin/app-risk-tags/:id", Description: "更新风险标签"},
{ApiGroup: "风控标签", Method: "DELETE", Path: "/admin/app-risk-tags/:id", Description: "删除风险标签"},
{ApiGroup: "风控标签", Method: "GET", Path: "/admin/app-users/:id/risk-tags", Description: "获取玩家风险标签"},
{ApiGroup: "风控标签", Method: "POST", Path: "/admin/app-users/:id/risk-tags", Description: "给玩家打标"},
{ApiGroup: "风控标签", Method: "DELETE", Path: "/admin/app-user-risk-tags/:id", Description: "移除玩家标签"},
{ApiGroup: "玩家登录日志", Method: "GET", Path: "/admin/app-login-logs", Description: "获取玩家登录日志列表"},
{ApiGroup: "邀请码管理", Method: "GET", Path: "/admin/app-invite-codes/:id", Description: "获取玩家邀请码详情"},
{ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/:id/revoke", Description: "作废玩家邀请码"},
{ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/issue", Description: "后台代用户生成邀请码"},
{ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/clear-unused", Description: "强制清空用户未使用邀请码"},
{ApiGroup: "玩家端-个人中心", Method: "GET", Path: "/app/me", Description: "获取当前玩家信息"},
{ApiGroup: "玩家端-邀请码", Method: "GET", Path: "/app/invite-code/current", Description: "获取当前未使用邀请码"},
{ApiGroup: "玩家端-邀请码", Method: "POST", Path: "/app/invite-code/generate", Description: "生成邀请码"},
{ApiGroup: "玩家端-注册登录", Method: "GET", Path: "/public/app/register/status", Description: "获取注册状态"},
{ApiGroup: "玩家端-注册登录", Method: "POST", Path: "/public/app/register", Description: "玩家注册"},
{ApiGroup: "玩家端-注册登录", Method: "POST", Path: "/public/app/login", Description: "玩家登录"},
})
}
func (i *initApi) InitializerName() string {
@@ -67,17 +141,31 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "系统用户", Method: "POST", Path: "/user/resetPassword", Description: "重置用户密码"},
{ApiGroup: "系统用户", Method: "PUT", Path: "/user/setSelfSetting", Description: "用户界面配置"},
{ApiGroup: "api", Method: "POST", Path: "/api/createApi", Description: "创建api"},
{ApiGroup: "api", Method: "POST", Path: "/api/deleteApi", Description: "删除Api"},
{ApiGroup: "api", Method: "POST", Path: "/api/updateApi", Description: "更新Api"},
{ApiGroup: "api", Method: "POST", Path: "/api/getApiList", Description: "获取api列表"},
{ApiGroup: "api", Method: "POST", Path: "/api/getAllApis", Description: "获取所有api"},
{ApiGroup: "api", Method: "POST", Path: "/api/getApiById", Description: "获取api详细信息"},
{ApiGroup: "api", Method: "DELETE", Path: "/api/deleteApisByIds", Description: "批量删除api"},
{ApiGroup: "api", Method: "GET", Path: "/api/syncApi", Description: "获取待同步API"},
{ApiGroup: "api", Method: "GET", Path: "/api/getApiGroups", Description: "获取路由组"},
{ApiGroup: "api", Method: "POST", Path: "/api/enterSyncApi", Description: "确认同步API"},
{ApiGroup: "api", Method: "POST", Path: "/api/ignoreApi", Description: "忽略API"},
{ApiGroup: "API管理", Method: "POST", Path: "/api/createApi", Description: "创建api"},
{ApiGroup: "API管理", Method: "POST", Path: "/api/deleteApi", Description: "删除Api"},
{ApiGroup: "API管理", Method: "POST", Path: "/api/updateApi", Description: "更新Api"},
{ApiGroup: "API管理", Method: "POST", Path: "/api/getApiList", Description: "获取api列表"},
{ApiGroup: "API管理", Method: "POST", Path: "/api/getAllApis", Description: "获取所有api"},
{ApiGroup: "API管理", Method: "POST", Path: "/api/getApiById", Description: "获取api详细信息"},
{ApiGroup: "API管理", Method: "DELETE", Path: "/api/deleteApisByIds", Description: "批量删除api"},
{ApiGroup: "API管理", Method: "GET", Path: "/api/syncApi", Description: "获取待同步API"},
{ApiGroup: "API管理", Method: "GET", Path: "/api/getApiGroups", Description: "获取路由组"},
{ApiGroup: "API管理", Method: "POST", Path: "/api/enterSyncApi", Description: "确认同步API"},
{ApiGroup: "API管理", Method: "POST", Path: "/api/ignoreApi", Description: "忽略API"},
// 玩家端(登录态)
{ApiGroup: "玩家端-个人中心", Method: "GET", Path: "/app/me", Description: "获取当前玩家信息"},
{ApiGroup: "玩家端-邀请码", Method: "GET", Path: "/app/invite-code/current", Description: "获取当前未使用邀请码"},
{ApiGroup: "玩家端-邀请码", Method: "POST", Path: "/app/invite-code/generate", Description: "生成邀请码"},
// 玩家端(公开:注册/登录)
{ApiGroup: "玩家端-注册登录", Method: "GET", Path: "/public/app/register/status", Description: "获取注册状态"},
{ApiGroup: "玩家端-注册登录", Method: "POST", Path: "/public/app/register", Description: "玩家注册"},
{ApiGroup: "玩家端-注册登录", Method: "POST", Path: "/public/app/login", Description: "玩家登录"},
// 用户前端system/public
{ApiGroup: "用户前端-注册", Method: "GET", Path: "/public/auth/register/status", Description: "获取注册状态"},
{ApiGroup: "用户前端-注册", Method: "POST", Path: "/public/auth/register", Description: "账号注册"},
{ApiGroup: "角色", Method: "POST", Path: "/authority/copyAuthority", Description: "拷贝角色"},
{ApiGroup: "角色", Method: "POST", Path: "/authority/createAuthority", Description: "创建角色"},
@@ -115,6 +203,26 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "系统服务", Method: "POST", Path: "/system/getServerInfo", Description: "获取服务器信息"},
{ApiGroup: "系统服务", Method: "POST", Path: "/system/getSystemConfig", Description: "获取配置文件内容"},
{ApiGroup: "系统服务", Method: "POST", Path: "/system/setSystemConfig", Description: "设置配置文件内容"},
{ApiGroup: "运营大屏", Method: "GET", Path: "/admin/dashboard/stats", Description: "仪表盘统计数据"},
{ApiGroup: "邀请码", Method: "POST", Path: "/user/invite-code/generate", Description: "生成邀请码"},
{ApiGroup: "邀请码", Method: "GET", Path: "/user/invite-code/current", Description: "获取当前未使用邀请码"},
{ApiGroup: "玩家管理", Method: "GET", Path: "/admin/app-users", Description: "获取玩家列表"},
{ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users", Description: "手动新增玩家"},
{ApiGroup: "玩家管理", Method: "GET", Path: "/admin/app-users/:id", Description: "获取玩家详情"},
{ApiGroup: "玩家管理", Method: "GET", Path: "/admin/app-users/:id/overview", Description: "获取玩家聚合概览"},
{ApiGroup: "玩家管理", Method: "PATCH", Path: "/admin/app-users/:id", Description: "编辑玩家(仅启用状态/欢迎词)"},
{ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/reset-password", Description: "重置玩家密码"},
{ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/toggle-enable", Description: "启用/停用玩家"},
{ApiGroup: "玩家管理", Method: "DELETE", Path: "/admin/app-users/:id", Description: "软删除玩家"},
{ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/recharge", Description: "后台手动充值(账户余额/游戏币)"},
{ApiGroup: "玩家资产流水", Method: "GET", Path: "/admin/app-asset-transactions", Description: "获取玩家资产流水列表"},
{ApiGroup: "玩家资产流水", Method: "GET", Path: "/admin/app-asset-transactions/:id", Description: "获取玩家资产流水详情"},
{ApiGroup: "玩家登录日志", Method: "GET", Path: "/admin/app-login-logs", Description: "获取玩家登录日志列表"},
{ApiGroup: "邀请码管理", Method: "GET", Path: "/admin/app-invite-codes", Description: "获取玩家邀请码列表"},
{ApiGroup: "邀请码管理", Method: "GET", Path: "/admin/app-invite-codes/:id", Description: "获取玩家邀请码详情"},
{ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/:id/revoke", Description: "作废玩家邀请码"},
{ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/issue", Description: "后台代用户生成邀请码"},
{ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/clear-unused", Description: "强制清空用户未使用邀请码"},
{ApiGroup: "MCP管理", Method: "GET", Path: "/mcp/status", Description: "获取 MCP 独立服务状态"},
{ApiGroup: "MCP管理", Method: "POST", Path: "/mcp/start", Description: "启动 MCP 独立服务"},
@@ -174,10 +282,27 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/findSysParams", Description: "根据ID获取参数"},
{ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getSysParamsList", Description: "获取参数列表"},
{ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getSysParam", Description: "获取参数列表"},
{ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getChangeLogList", Description: "获取参数变更审计"},
{ApiGroup: "媒体库分类", Method: "GET", Path: "/attachmentCategory/getCategoryList", Description: "分类列表"},
{ApiGroup: "媒体库分类", Method: "POST", Path: "/attachmentCategory/addCategory", Description: "添加/编辑分类"},
{ApiGroup: "媒体库分类", Method: "POST", Path: "/attachmentCategory/deleteCategory", Description: "删除分类"},
}
// register default meta for SyncApi prefill (best-effort)
if global.GVA_API_DEFAULT_META == nil {
global.GVA_API_DEFAULT_META = make(map[string]struct {
ApiGroup string
Description string
}, len(entities))
}
for _, e := range entities {
key := strings.ToUpper(strings.TrimSpace(e.Method)) + " " + strings.TrimSpace(e.Path)
if _, ok := global.GVA_API_DEFAULT_META[key]; !ok {
global.GVA_API_DEFAULT_META[key] = struct {
ApiGroup string
Description string
}{ApiGroup: e.ApiGroup, Description: e.Description}
}
}
if err := db.Create(&entities).Error; err != nil {
return ctx, errors.Wrap(err, sysModel.SysApi{}.TableName()+"表数据初始化失败!")
}

View File

@@ -117,6 +117,38 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "888", V1: "/system/getSystemConfig", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/system/setSystemConfig", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/system/getServerInfo", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/dashboard/stats", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-users", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-users", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-users/:id", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-users/:id/overview", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-users/:id", V2: "PATCH"},
{Ptype: "p", V0: "888", V1: "/admin/app-users/:id/reset-password", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-users/:id/toggle-enable", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-users/:id", V2: "DELETE"},
{Ptype: "p", V0: "888", V1: "/admin/app-users/:id/recharge", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-asset-transactions", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-asset-transactions/:id", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders/:id", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders/:id/approve", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders/:id/reject", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders/:id/mark-paid", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders/:id/mark-failed", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-risk-tags", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-risk-tags", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-risk-tags/:id", V2: "PUT"},
{Ptype: "p", V0: "888", V1: "/admin/app-risk-tags/:id", V2: "DELETE"},
{Ptype: "p", V0: "888", V1: "/admin/app-users/:id/risk-tags", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-users/:id/risk-tags", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-user-risk-tags/:id", V2: "DELETE"},
{Ptype: "p", V0: "888", V1: "/admin/app-login-logs", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-invite-codes", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-invite-codes/:id", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/admin/app-invite-codes/:id/revoke", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-invite-codes/issue", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/admin/app-invite-codes/clear-unused", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/mcp/status", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/mcp/start", V2: "POST"},
@@ -176,6 +208,7 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "888", V1: "/sysParams/findSysParams", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysParams/getSysParamsList", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysParams/getSysParam", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysParams/getChangeLogList", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/attachmentCategory/getCategoryList", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/attachmentCategory/addCategory", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/attachmentCategory/deleteCategory", V2: "POST"},
@@ -216,6 +249,39 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "8881", V1: "/system/getSystemConfig", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/system/setSystemConfig", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/user/getUserInfo", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/dashboard/stats", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-users", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-users", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-users/:id", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/overview", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-users/:id", V2: "PATCH"},
{Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/reset-password", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/toggle-enable", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-users/:id", V2: "DELETE"},
{Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/recharge", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-asset-transactions", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-asset-transactions/:id", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders/:id", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders/:id/approve", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders/:id/reject", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders/:id/mark-paid", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders/:id/mark-failed", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-risk-tags", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-risk-tags", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-risk-tags/:id", V2: "PUT"},
{Ptype: "p", V0: "8881", V1: "/admin/app-risk-tags/:id", V2: "DELETE"},
{Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/risk-tags", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/risk-tags", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-user-risk-tags/:id", V2: "DELETE"},
{Ptype: "p", V0: "8881", V1: "/admin/app-login-logs", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-invite-codes", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-invite-codes/:id", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/admin/app-invite-codes/:id/revoke", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-invite-codes/issue", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/admin/app-invite-codes/clear-unused", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/sysParams/getChangeLogList", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/user/admin_register", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/api/createApi", V2: "POST"},
@@ -260,7 +326,42 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "9528", V1: "/mcp/tools", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/mcp/test", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/mcp/createTool", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/user/invite-code/generate", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/user/invite-code/current", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/user/getUserInfo", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/dashboard/stats", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-users", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-users", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-users/:id", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/overview", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-users/:id", V2: "PATCH"},
{Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/reset-password", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/toggle-enable", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-users/:id", V2: "DELETE"},
{Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/recharge", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-asset-transactions", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-asset-transactions/:id", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders/:id", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders/:id/approve", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders/:id/reject", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders/:id/mark-paid", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders/:id/mark-failed", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-risk-tags", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-risk-tags", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-risk-tags/:id", V2: "PUT"},
{Ptype: "p", V0: "9528", V1: "/admin/app-risk-tags/:id", V2: "DELETE"},
{Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/risk-tags", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/risk-tags", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-user-risk-tags/:id", V2: "DELETE"},
{Ptype: "p", V0: "9528", V1: "/admin/app-login-logs", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-invite-codes", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-invite-codes/:id", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/admin/app-invite-codes/:id/revoke", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-invite-codes/issue", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/admin/app-invite-codes/clear-unused", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/sysParams/getChangeLogList", V2: "GET"},
}
if err := db.Create(&entities).Error; err != nil {
return ctx, errors.Wrap(err, "Casbin 表 ("+i.InitializerName()+") 数据初始化失败!")

View File

@@ -0,0 +1,223 @@
package system
import (
"context"
. "git.echol.cn/loser/Go-Web-Template/server/model/system"
"git.echol.cn/loser/Go-Web-Template/server/service/system"
"github.com/pkg/errors"
"gorm.io/gorm"
)
// 该 initializer 用于把「菜单 DB 数据」与「前端文件路由」对齐:
// - 按 Name 精准匹配,幂等修正 path/parent/component避免出现“前端显示与实际路由不一致”的别名情况。
// - 若缺少必要父级/子级菜单,则补齐到目标结构。
//
// 注意:它不会删除菜单,只做修正/补齐,避免误伤线上已有配置。
const initOrderMenuRepair = initOrderMenu + 1
type menuRepair struct{}
func init() {
system.RegisterInit(initOrderMenuRepair, &menuRepair{})
}
func (m *menuRepair) InitializerName() string {
return "sys_base_menus_repair"
}
func (m *menuRepair) MigrateTable(ctx context.Context) (context.Context, error) {
// 仅修复数据,不建表
return ctx, nil
}
func (m *menuRepair) TableCreated(ctx context.Context) bool {
// 仅修复数据,不参与建表逻辑
return true
}
func (m *menuRepair) DataInserted(ctx context.Context) bool {
// 每次都允许运行(幂等修复),因此返回 false 以触发 InitializeData
return false
}
type menuSeed struct {
level uint
hidden bool
parentName string // 空表示根节点
path string
name string
component string
sort int
title string
icon string
}
func ensureMenu(tx *gorm.DB, seed menuSeed, parentID uint) (*SysBaseMenu, error) {
var menu SysBaseMenu
err := tx.Where("name = ?", seed.name).First(&menu).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
menu = SysBaseMenu{
MenuLevel: seed.level,
ParentId: parentID,
Path: seed.path,
Name: seed.name,
Hidden: seed.hidden,
Component: seed.component,
Sort: seed.sort,
Meta: Meta{
Title: seed.title,
Icon: seed.icon,
},
}
if err := tx.Create(&menu).Error; err != nil {
return nil, err
}
return &menu, nil
}
updates := map[string]any{
"menu_level": seed.level,
"parent_id": parentID,
"path": seed.path,
"component": seed.component,
}
if err := tx.Model(&menu).Updates(updates).Error; err != nil {
return nil, err
}
// 尽量不覆盖用户自定义标题/图标:仅在为空时补齐
metaUpdates := map[string]any{}
if menu.Meta.Title == "" && seed.title != "" {
metaUpdates["title"] = seed.title
}
if menu.Meta.Icon == "" && seed.icon != "" {
metaUpdates["icon"] = seed.icon
}
if len(metaUpdates) > 0 {
if err := tx.Model(&menu).Updates(metaUpdates).Error; err != nil {
return nil, err
}
}
return &menu, nil
}
func (m *menuRepair) InitializeData(ctx context.Context) (next context.Context, err error) {
db, ok := ctx.Value("db").(*gorm.DB)
if !ok {
return ctx, system.ErrMissingDBContext
}
seeds := []menuSeed{
// 父级(若不存在则补齐)
{
level: 0,
hidden: false,
path: "userCenter",
name: "userCenter",
component: "features/discovery/ModuleLandingPage:userCenter",
sort: 2,
title: "用户中心",
icon: "AppstoreOutlined",
},
{
level: 0,
hidden: false,
path: "opsConfig",
name: "opsConfig",
component: "features/discovery/ModuleLandingPage:opsConfig",
sort: 6,
title: "运营配置",
icon: "SettingOutlined",
},
{
level: 0,
hidden: false,
path: "common",
name: "common",
component: "features/discovery/ModuleLandingPage:common",
sort: 6,
title: "公共能力",
icon: "AppstoreOutlined",
},
// 子级(目标规范路径)
{
level: 1,
hidden: false,
parentName: "userCenter",
path: "appLoginLog",
name: "appLoginLog",
component: "features/appLoginLogs/AppLoginLogsPage",
sort: 99,
title: "登录日志",
icon: "FileTextOutlined",
},
{
level: 1,
hidden: false,
parentName: "opsConfig",
path: "sysParamChangeLog",
name: "sysParamChangeLog",
component: "features/params/SysParamChangeLogPage",
sort: 99,
title: "参数变更日志",
icon: "SettingOutlined",
},
{
level: 1,
hidden: false,
parentName: "common",
path: "upload",
name: "upload",
component: "features/media/MediaLibraryPage",
sort: 1,
title: "媒体库(上传下载)",
icon: "UploadOutlined",
},
}
err = db.Transaction(func(tx *gorm.DB) error {
byName := map[string]*SysBaseMenu{}
// 先确保父级存在(包含 common 也一起修正 component/path
for _, seed := range seeds {
if seed.level != 0 {
continue
}
menu, err := ensureMenu(tx, seed, 0)
if err != nil {
return errors.Wrap(err, "ensure parent menu failed")
}
byName[seed.name] = menu
}
// 再确保子级存在并挂载到父级
for _, seed := range seeds {
if seed.level == 0 {
continue
}
parent := byName[seed.parentName]
if parent == nil {
return errors.Errorf("missing parent menu %s for %s", seed.parentName, seed.name)
}
if _, err := ensureMenu(tx, seed, parent.ID); err != nil {
return errors.Wrap(err, "ensure child menu failed")
}
}
return nil
})
if err != nil {
return ctx, err
}
return ctx, nil
}

View File

@@ -2,6 +2,7 @@ package system
import (
"context"
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
"git.echol.cn/loser/Go-Web-Template/server/service/system"
"git.echol.cn/loser/Go-Web-Template/server/utils"
@@ -59,21 +60,21 @@ func (i *initUser) InitializeData(ctx context.Context) (next context.Context, er
UUID: uuid.New(),
Username: "admin",
Password: adminPassword,
NickName: "Mr.奇淼",
HeaderImg: "https://qmplusimg.henrongyi.top/gva_header.jpg",
NickName: "超级管理员",
HeaderImg: "https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y",
AuthorityId: 888,
Phone: "17611111111",
Email: "333333333@qq.com",
Phone: "13888888888",
Email: "admin@example.com",
},
{
UUID: uuid.New(),
Username: "a303176530",
Username: "JohnDoe",
Password: password,
NickName: "用户1",
HeaderImg: "https://qmplusimg.henrongyi.top/1572075907logo.png",
NickName: "测试用户",
HeaderImg: "https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y",
AuthorityId: 9528,
Phone: "17611111111",
Email: "333333333@qq.com"},
Phone: "13999999999",
Email: "test@example.com"},
}
if err = db.Create(&entities).Error; err != nil {
return ctx, errors.Wrap(err, sysModel.SysUser{}.TableName()+"表数据初始化失败!")

View File

@@ -136,7 +136,7 @@ func GetUserName(c *gin.Context) string {
func LoginToken(user system.Login) (token string, claims systemReq.CustomClaims, err error) {
j := NewJWT()
claims = j.CreateClaims(systemReq.BaseClaims{
claims = j.CreateAdminClaims(systemReq.BaseClaims{
UUID: user.GetUUID(),
ID: user.GetUserId(),
NickName: user.GetNickname(),

View File

@@ -14,6 +14,11 @@ type JWT struct {
SigningKey []byte
}
const (
JWTAudienceAdmin = "GVA_ADMIN"
JWTAudienceApp = "GVA_APP"
)
var (
TokenValid = errors.New("未知错误")
TokenExpired = errors.New("token已过期")
@@ -29,14 +34,14 @@ func NewJWT() *JWT {
}
}
func (j *JWT) CreateClaims(baseClaims request.BaseClaims) request.CustomClaims {
func (j *JWT) CreateClaimsWithAudience(baseClaims request.BaseClaims, aud string) request.CustomClaims {
bf, _ := ParseDuration(global.GVA_CONFIG.JWT.BufferTime)
ep, _ := ParseDuration(global.GVA_CONFIG.JWT.ExpiresTime)
claims := request.CustomClaims{
BaseClaims: baseClaims,
BufferTime: int64(bf / time.Second), // 缓冲时间1天 缓冲时间内会获得新的token刷新令牌 此时一个用户会存在两个有效令牌 但是前端只留一个 另一个会丢失
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{"GVA"}, // 受众
Audience: jwt.ClaimStrings{aud}, // 受众
NotBefore: jwt.NewNumericDate(time.Now().Add(-1000)), // 签名生效时间
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ep)), // 过期时间 7天 配置文件
Issuer: global.GVA_CONFIG.JWT.Issuer, // 签名的发行者
@@ -45,6 +50,14 @@ func (j *JWT) CreateClaims(baseClaims request.BaseClaims) request.CustomClaims {
return claims
}
func (j *JWT) CreateAdminClaims(baseClaims request.BaseClaims) request.CustomClaims {
return j.CreateClaimsWithAudience(baseClaims, JWTAudienceAdmin)
}
func (j *JWT) CreateAppClaims(baseClaims request.BaseClaims) request.CustomClaims {
return j.CreateClaimsWithAudience(baseClaims, JWTAudienceApp)
}
// CreateToken 创建一个token
func (j *JWT) CreateToken(claims request.CustomClaims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)