✨ 新增几大中心功能
This commit is contained in:
1
AntiCAP
Submodule
1
AntiCAP
Submodule
Submodule AntiCAP added at 672e7b1cd9
234
server/api/v1/admin/app_asset_transactions.go
Normal file
234
server/api/v1/admin/app_asset_transactions.go
Normal 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)
|
||||||
|
}
|
||||||
301
server/api/v1/admin/app_invite_codes.go
Normal file
301
server/api/v1/admin/app_invite_codes.go
Normal 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)
|
||||||
|
}
|
||||||
138
server/api/v1/admin/app_login_logs.go
Normal file
138
server/api/v1/admin/app_login_logs.go
Normal 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)
|
||||||
|
}
|
||||||
283
server/api/v1/admin/app_risk_tags.go
Normal file
283
server/api/v1/admin/app_risk_tags.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
704
server/api/v1/admin/app_users.go
Normal file
704
server/api/v1/admin/app_users.go
Normal 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 ""
|
||||||
|
}
|
||||||
528
server/api/v1/admin/app_withdraw_orders.go
Normal file
528
server/api/v1/admin/app_withdraw_orders.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
22
server/api/v1/admin/dashboard.go
Normal file
22
server/api/v1/admin/dashboard.go
Normal 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)
|
||||||
|
}
|
||||||
11
server/api/v1/admin/enter.go
Normal file
11
server/api/v1/admin/enter.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
type ApiGroup struct {
|
||||||
|
DashboardApi
|
||||||
|
AppUsersApi
|
||||||
|
AppInviteCodesApi
|
||||||
|
AppLoginLogsApi
|
||||||
|
AppAssetTransactionsApi
|
||||||
|
AppWithdrawOrdersApi
|
||||||
|
AppRiskTagsApi
|
||||||
|
}
|
||||||
37
server/api/v1/app/app_auth.go
Normal file
37
server/api/v1/app/app_auth.go
Normal 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)
|
||||||
|
}
|
||||||
7
server/api/v1/app/enter.go
Normal file
7
server/api/v1/app/enter.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
type ApiGroup struct {
|
||||||
|
PublicAuthApi
|
||||||
|
AppAuthApi
|
||||||
|
InviteCodeApi
|
||||||
|
}
|
||||||
50
server/api/v1/app/invite_code.go
Normal file
50
server/api/v1/app/invite_code.go
Normal 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)
|
||||||
|
}
|
||||||
122
server/api/v1/app/public_auth.go
Normal file
122
server/api/v1/app/public_auth.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
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/common"
|
||||||
"git.echol.cn/loser/Go-Web-Template/server/api/v1/system"
|
"git.echol.cn/loser/Go-Web-Template/server/api/v1/system"
|
||||||
)
|
)
|
||||||
@@ -10,4 +12,6 @@ var ApiGroupApp = new(ApiGroup)
|
|||||||
type ApiGroup struct {
|
type ApiGroup struct {
|
||||||
SystemApiGroup system.ApiGroup
|
SystemApiGroup system.ApiGroup
|
||||||
CommonApiGroup common.ApiGroup
|
CommonApiGroup common.ApiGroup
|
||||||
|
AdminApiGroup admin.ApiGroup
|
||||||
|
AppApiGroup app.ApiGroup
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ type ApiGroup struct {
|
|||||||
JwtApi
|
JwtApi
|
||||||
BaseApi
|
BaseApi
|
||||||
SystemApi
|
SystemApi
|
||||||
|
PublicAuthApi
|
||||||
|
InviteCodeApi
|
||||||
CasbinApi
|
CasbinApi
|
||||||
SystemApiApi
|
SystemApiApi
|
||||||
AuthorityApi
|
AuthorityApi
|
||||||
@@ -41,4 +43,6 @@ var (
|
|||||||
sysErrorService = service.ServiceGroupApp.SystemServiceGroup.SysErrorService
|
sysErrorService = service.ServiceGroupApp.SystemServiceGroup.SysErrorService
|
||||||
loginLogService = service.ServiceGroupApp.SystemServiceGroup.LoginLogService
|
loginLogService = service.ServiceGroupApp.SystemServiceGroup.LoginLogService
|
||||||
apiTokenService = service.ServiceGroupApp.SystemServiceGroup.ApiTokenService
|
apiTokenService = service.ServiceGroupApp.SystemServiceGroup.ApiTokenService
|
||||||
|
inviteCodeService = service.ServiceGroupApp.SystemServiceGroup.InviteCodeService
|
||||||
|
publicRegisterService = service.ServiceGroupApp.SystemServiceGroup.PublicRegisterService
|
||||||
)
|
)
|
||||||
|
|||||||
63
server/api/v1/system/invite_code.go
Normal file
63
server/api/v1/system/invite_code.go
Normal 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)
|
||||||
|
}
|
||||||
28
server/api/v1/system/public_auth.go
Normal file
28
server/api/v1/system/public_auth.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
package system
|
package system
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.echol.cn/loser/Go-Web-Template/server/global"
|
"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/common/response"
|
||||||
"git.echol.cn/loser/Go-Web-Template/server/model/system"
|
"git.echol.cn/loser/Go-Web-Template/server/model/system"
|
||||||
systemReq "git.echol.cn/loser/Go-Web-Template/server/model/system/request"
|
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/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -33,6 +37,18 @@ func (sysParamsApi *SysParamsApi) CreateSysParams(c *gin.Context) {
|
|||||||
response.FailWithMessage("创建失败:"+err.Error(), c)
|
response.FailWithMessage("创建失败:"+err.Error(), c)
|
||||||
return
|
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)
|
response.OkWithMessage("创建成功", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +63,26 @@ func (sysParamsApi *SysParamsApi) CreateSysParams(c *gin.Context) {
|
|||||||
// @Router /sysParams/deleteSysParams [delete]
|
// @Router /sysParams/deleteSysParams [delete]
|
||||||
func (sysParamsApi *SysParamsApi) DeleteSysParams(c *gin.Context) {
|
func (sysParamsApi *SysParamsApi) DeleteSysParams(c *gin.Context) {
|
||||||
ID := c.Query("ID")
|
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)
|
err := sysParamsService.DeleteSysParams(ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
global.GVA_LOG.Error("删除失败!", zap.Error(err))
|
global.GVA_LOG.Error("删除失败!", zap.Error(err))
|
||||||
response.FailWithMessage("删除失败:"+err.Error(), c)
|
response.FailWithMessage("删除失败:"+err.Error(), c)
|
||||||
return
|
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)
|
response.OkWithMessage("删除成功", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +121,97 @@ func (sysParamsApi *SysParamsApi) UpdateSysParams(c *gin.Context) {
|
|||||||
response.FailWithMessage(err.Error(), c)
|
response.FailWithMessage(err.Error(), c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// read before update for audit
|
||||||
|
var before system.SysParams
|
||||||
|
_ = global.GVA_DB.Where("id = ?", sysParams.ID).First(&before).Error
|
||||||
err = sysParamsService.UpdateSysParams(sysParams)
|
err = sysParamsService.UpdateSysParams(sysParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
global.GVA_LOG.Error("更新失败!", zap.Error(err))
|
global.GVA_LOG.Error("更新失败!", zap.Error(err))
|
||||||
response.FailWithMessage("更新失败:"+err.Error(), c)
|
response.FailWithMessage("更新失败:"+err.Error(), c)
|
||||||
return
|
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)
|
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查询参数
|
// FindSysParams 用id查询参数
|
||||||
// @Tags SysParams
|
// @Tags SysParams
|
||||||
// @Summary 用id查询参数
|
// @Summary 用id查询参数
|
||||||
|
|||||||
251
server/config.debug.yaml
Normal file
251
server/config.debug.yaml
Normal 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
|
||||||
1140
server/docs/docs.go
1140
server/docs/docs.go
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,166 @@
|
|||||||
definitions:
|
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:
|
common.ExaAttachmentCategory:
|
||||||
properties:
|
properties:
|
||||||
ID:
|
ID:
|
||||||
@@ -216,11 +378,6 @@ definitions:
|
|||||||
description: 收件人:多个以英文逗号分隔 例:a@qq.com b@qq.com 正式开发中请把此项目作为参数使用
|
description: 收件人:多个以英文逗号分隔 例:a@qq.com b@qq.com 正式开发中请把此项目作为参数使用
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
config.Excel:
|
|
||||||
properties:
|
|
||||||
dir:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
config.HuaWeiObs:
|
config.HuaWeiObs:
|
||||||
properties:
|
properties:
|
||||||
access-key:
|
access-key:
|
||||||
@@ -251,8 +408,11 @@ definitions:
|
|||||||
type: object
|
type: object
|
||||||
config.Local:
|
config.Local:
|
||||||
properties:
|
properties:
|
||||||
|
base-url:
|
||||||
|
description: 文件访问域名前缀,如 https://api.wanjia.ai
|
||||||
|
type: string
|
||||||
path:
|
path:
|
||||||
description: 本地文件访问路径
|
description: 本地文件访问路径(相对路径)
|
||||||
type: string
|
type: string
|
||||||
store-path:
|
store-path:
|
||||||
description: 本地文件存储路径
|
description: 本地文件存储路径
|
||||||
@@ -597,8 +757,6 @@ definitions:
|
|||||||
type: array
|
type: array
|
||||||
email:
|
email:
|
||||||
$ref: '#/definitions/config.Email'
|
$ref: '#/definitions/config.Email'
|
||||||
excel:
|
|
||||||
$ref: '#/definitions/config.Excel'
|
|
||||||
hua-wei-obs:
|
hua-wei-obs:
|
||||||
$ref: '#/definitions/config.HuaWeiObs'
|
$ref: '#/definitions/config.HuaWeiObs'
|
||||||
jwt:
|
jwt:
|
||||||
@@ -812,6 +970,21 @@ definitions:
|
|||||||
description: 栈名
|
description: 栈名
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
request.AddMenuAuthorityInfo:
|
||||||
properties:
|
properties:
|
||||||
authorityId:
|
authorityId:
|
||||||
@@ -951,21 +1124,6 @@ definitions:
|
|||||||
- adminPassword
|
- adminPassword
|
||||||
- dbName
|
- dbName
|
||||||
type: object
|
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:
|
request.PageInfo:
|
||||||
properties:
|
properties:
|
||||||
keyword:
|
keyword:
|
||||||
@@ -1231,6 +1389,15 @@ definitions:
|
|||||||
user:
|
user:
|
||||||
$ref: '#/definitions/system.SysUser'
|
$ref: '#/definitions/system.SysUser'
|
||||||
type: object
|
type: object
|
||||||
|
system.InviteCodeGenerated:
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
codeLast4:
|
||||||
|
type: string
|
||||||
|
expiresAt:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
system.Meta:
|
system.Meta:
|
||||||
properties:
|
properties:
|
||||||
activeName:
|
activeName:
|
||||||
@@ -1691,18 +1858,410 @@ definitions:
|
|||||||
uuid:
|
uuid:
|
||||||
description: 用户UUID
|
description: 用户UUID
|
||||||
type: string
|
type: string
|
||||||
|
welcomePhrase:
|
||||||
|
description: 安全欢迎词(用户自定义)
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
system.System:
|
system.System:
|
||||||
properties:
|
properties:
|
||||||
config:
|
config:
|
||||||
$ref: '#/definitions/config.Server'
|
$ref: '#/definitions/config.Server'
|
||||||
type: object
|
type: object
|
||||||
|
system.inviteCodeCurrentResponse:
|
||||||
|
properties:
|
||||||
|
hasUnused:
|
||||||
|
type: boolean
|
||||||
|
record: {}
|
||||||
|
type: object
|
||||||
info:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
description: 使用gin+react进行极速开发的全栈开发基础平台
|
description: 使用gin+react进行极速开发的全栈开发基础平台
|
||||||
title: Gin-React-Admin Swagger API接口文档
|
title: Gin-React-Admin Swagger API接口文档
|
||||||
version: v2.9.1
|
version: v2.9.1
|
||||||
paths:
|
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:
|
/api/createApi:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
@@ -2475,7 +3034,7 @@ paths:
|
|||||||
name: data
|
name: data
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/request.Login'
|
$ref: '#/definitions/git_echol_cn_loser_Go-Web-Template_server_model_system_request.Login'
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -3155,6 +3714,40 @@ paths:
|
|||||||
summary: 更新菜单
|
summary: 更新菜单
|
||||||
tags:
|
tags:
|
||||||
- Menu
|
- 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:
|
/sysDictionary/createSysDictionary:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
@@ -4742,6 +5335,48 @@ paths:
|
|||||||
summary: 分页获取用户列表
|
summary: 分页获取用户列表
|
||||||
tags:
|
tags:
|
||||||
- SysUser
|
- 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:
|
/user/resetPassword:
|
||||||
post:
|
post:
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
@@ -36,10 +36,16 @@ var (
|
|||||||
GVA_Timer timer.Timer = timer.NewTimerTask()
|
GVA_Timer timer.Timer = timer.NewTimerTask()
|
||||||
GVA_Concurrency_Control = &singleflight.Group{}
|
GVA_Concurrency_Control = &singleflight.Group{}
|
||||||
GVA_ROUTERS gin.RoutesInfo
|
GVA_ROUTERS gin.RoutesInfo
|
||||||
GVA_ACTIVE_DBNAME *string
|
// GVA_API_DEFAULT_META 用于在“同步路由”时给新增 API 预填分组与简介
|
||||||
GVA_MCP_SERVER *server.MCPServer
|
// key: METHOD + " " + PATH (e.g. "GET /admin/app-users")
|
||||||
BlackCache local_cache.Cache
|
GVA_API_DEFAULT_META map[string]struct {
|
||||||
lock sync.RWMutex
|
ApiGroup string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
GVA_ACTIVE_DBNAME *string
|
||||||
|
GVA_MCP_SERVER *server.MCPServer
|
||||||
|
BlackCache local_cache.Cache
|
||||||
|
lock sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetGlobalDBByDBName 通过名称获取db list中的db
|
// GetGlobalDBByDBName 通过名称获取db list中的db
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func (e *ensureTables) MigrateTable(ctx context.Context) (context.Context, error
|
|||||||
sysModel.SysBaseMenuBtn{},
|
sysModel.SysBaseMenuBtn{},
|
||||||
sysModel.SysAuthorityBtn{},
|
sysModel.SysAuthorityBtn{},
|
||||||
sysModel.SysParams{},
|
sysModel.SysParams{},
|
||||||
|
sysModel.SysParamChangeLog{},
|
||||||
sysModel.SysError{},
|
sysModel.SysError{},
|
||||||
sysModel.SysLoginLog{},
|
sysModel.SysLoginLog{},
|
||||||
sysModel.SysApiToken{},
|
sysModel.SysApiToken{},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.echol.cn/loser/Go-Web-Template/server/global"
|
"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"
|
commonModel "git.echol.cn/loser/Go-Web-Template/server/model/common"
|
||||||
"git.echol.cn/loser/Go-Web-Template/server/model/system"
|
"git.echol.cn/loser/Go-Web-Template/server/model/system"
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ func RegisterTables() {
|
|||||||
system.SysApi{},
|
system.SysApi{},
|
||||||
system.SysIgnoreApi{},
|
system.SysIgnoreApi{},
|
||||||
system.SysUser{},
|
system.SysUser{},
|
||||||
|
system.InviteCode{},
|
||||||
|
system.UserInviteRelation{},
|
||||||
system.SysBaseMenu{},
|
system.SysBaseMenu{},
|
||||||
system.JwtBlacklist{},
|
system.JwtBlacklist{},
|
||||||
system.SysAuthority{},
|
system.SysAuthority{},
|
||||||
@@ -56,6 +59,7 @@ func RegisterTables() {
|
|||||||
system.SysBaseMenuBtn{},
|
system.SysBaseMenuBtn{},
|
||||||
system.SysAuthorityBtn{},
|
system.SysAuthorityBtn{},
|
||||||
system.SysParams{},
|
system.SysParams{},
|
||||||
|
system.SysParamChangeLog{},
|
||||||
system.SysError{},
|
system.SysError{},
|
||||||
system.SysApiToken{},
|
system.SysApiToken{},
|
||||||
system.SysLoginLog{},
|
system.SysLoginLog{},
|
||||||
@@ -64,6 +68,18 @@ func RegisterTables() {
|
|||||||
commonModel.ExaFileChunk{},
|
commonModel.ExaFileChunk{},
|
||||||
commonModel.ExaFileUploadAndDownload{},
|
commonModel.ExaFileUploadAndDownload{},
|
||||||
commonModel.ExaAttachmentCategory{},
|
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 {
|
if err != nil {
|
||||||
global.GVA_LOG.Error("register table failed", zap.Error(err))
|
global.GVA_LOG.Error("register table failed", zap.Error(err))
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ func Routers() *gin.Engine {
|
|||||||
|
|
||||||
systemRouter := router.RouterGroupApp.System
|
systemRouter := router.RouterGroupApp.System
|
||||||
commonRouter := router.RouterGroupApp.Common
|
commonRouter := router.RouterGroupApp.Common
|
||||||
|
appRouter := router.RouterGroupApp.App
|
||||||
// 如果想要不使用nginx代理前端网页,可以修改 web/.env.production 下的
|
// 如果想要不使用nginx代理前端网页,可以修改 web/.env.production 下的
|
||||||
// VUE_APP_BASE_API = /
|
// VUE_APP_BASE_API = /
|
||||||
// VUE_APP_BASE_PATH = http://localhost
|
// VUE_APP_BASE_PATH = http://localhost
|
||||||
@@ -64,9 +65,11 @@ func Routers() *gin.Engine {
|
|||||||
// 方便统一添加路由组前缀 多服务器上线使用
|
// 方便统一添加路由组前缀 多服务器上线使用
|
||||||
|
|
||||||
PublicGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix)
|
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.InitBaseRouter(PublicGroup) // 注册基础功能路由 不做鉴权
|
||||||
systemRouter.InitInitRouter(PublicGroup) // 自动初始化相关
|
systemRouter.InitInitRouter(PublicGroup) // 自动初始化相关
|
||||||
|
systemRouter.InitPublicAuthRouter(PublicGroup) // 用户前端注册相关(public)
|
||||||
|
appRouter.InitPublicAuthRouter(PublicGroup) // 玩家端注册/登录(public)
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
systemRouter.InitApiRouter(PrivateGroup, PublicGroup) // 注册功能api路由
|
systemRouter.InitApiRouter(AdminGroup, PublicGroup) // 注册功能api路由
|
||||||
systemRouter.InitJwtRouter(PrivateGroup) // jwt相关路由
|
systemRouter.InitJwtRouter(AdminGroup) // jwt相关路由
|
||||||
systemRouter.InitUserRouter(PrivateGroup) // 注册用户路由
|
systemRouter.InitUserRouter(AdminGroup) // 注册用户路由
|
||||||
systemRouter.InitMenuRouter(PrivateGroup) // 注册menu路由
|
systemRouter.InitInviteCodeRouter(AdminGroup) // 邀请码(登录态)
|
||||||
systemRouter.InitSystemRouter(PrivateGroup) // system相关路由
|
systemRouter.InitMenuRouter(AdminGroup) // 注册menu路由
|
||||||
systemRouter.InitCasbinRouter(PrivateGroup) // 权限相关路由
|
systemRouter.InitSystemRouter(AdminGroup) // system相关路由
|
||||||
systemRouter.InitAuthorityRouter(PrivateGroup) // 注册角色路由
|
systemRouter.InitCasbinRouter(AdminGroup) // 权限相关路由
|
||||||
systemRouter.InitSysDictionaryRouter(PrivateGroup) // 字典管理
|
systemRouter.InitAuthorityRouter(AdminGroup) // 注册角色路由
|
||||||
systemRouter.InitSysOperationRecordRouter(PrivateGroup) // 操作记录
|
systemRouter.InitSysDictionaryRouter(AdminGroup) // 字典管理
|
||||||
systemRouter.InitSysDictionaryDetailRouter(PrivateGroup) // 字典详情管理
|
systemRouter.InitSysOperationRecordRouter(AdminGroup) // 操作记录
|
||||||
systemRouter.InitAuthorityBtnRouterRouter(PrivateGroup) // 按钮权限管理
|
systemRouter.InitSysDictionaryDetailRouter(AdminGroup) // 字典详情管理
|
||||||
systemRouter.InitMcpRouter(PrivateGroup) // MCP 管理
|
systemRouter.InitAuthorityBtnRouterRouter(AdminGroup) // 按钮权限管理
|
||||||
systemRouter.InitSysParamsRouter(PrivateGroup, PublicGroup) // 参数管理
|
systemRouter.InitMcpRouter(AdminGroup) // MCP 管理
|
||||||
systemRouter.InitSysErrorRouter(PrivateGroup, PublicGroup) // 错误日志
|
systemRouter.InitSysParamsRouter(AdminGroup, PublicGroup) // 参数管理
|
||||||
systemRouter.InitLoginLogRouter(PrivateGroup) // 登录日志
|
systemRouter.InitSysErrorRouter(AdminGroup, PublicGroup) // 错误日志
|
||||||
systemRouter.InitApiTokenRouter(PrivateGroup) // apiToken签发
|
systemRouter.InitLoginLogRouter(AdminGroup) // 登录日志
|
||||||
commonRouter.InitFileUploadAndDownloadRouter(PrivateGroup) // 文件上传下载功能路由
|
systemRouter.InitApiTokenRouter(AdminGroup) // apiToken签发
|
||||||
commonRouter.InitAttachmentCategoryRouterRouter(PrivateGroup) // 文件上传下载分类
|
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()
|
global.GVA_ROUTERS = Router.Routes()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package initialize
|
package initialize
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
v1 "git.echol.cn/loser/Go-Web-Template/server/api/v1"
|
||||||
"git.echol.cn/loser/Go-Web-Template/server/router"
|
"git.echol.cn/loser/Go-Web-Template/server/router"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -16,4 +17,42 @@ func initBizRouter(routers ...*gin.RouterGroup) {
|
|||||||
|
|
||||||
holder(publicGroup, privateGroup)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
34
server/middleware/app_activity.go
Normal file
34
server/middleware/app_activity.go
Normal 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,避免未来扩展时频繁改动(不影响逻辑)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
server/middleware/app_jwt.go
Normal file
74
server/middleware/app_jwt.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-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")
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
|
|
||||||
// 放行所有OPTIONS方法
|
// 放行所有OPTIONS方法(预检请求不应继续进入路由,否则易落到 404)
|
||||||
if method == "OPTIONS" {
|
if method == "OPTIONS" {
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// 处理请求
|
|
||||||
c.Next()
|
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") {
|
if whitelist == nil && global.GVA_CONFIG.Cors.Mode == "strict-whitelist" && !(c.Request.Method == "GET" && c.Request.URL.Path == "/health") {
|
||||||
c.AbortWithStatus(http.StatusForbidden)
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
} else {
|
return
|
||||||
// 非严格白名单模式,无论是否通过检查均放行所有 OPTIONS 方法
|
}
|
||||||
if c.Request.Method == http.MethodOptions {
|
// OPTIONS 预检:直接结束,避免继续匹配路由导致 404
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
if c.Request.Method == http.MethodOptions {
|
||||||
}
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理请求
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ func JWTAuth() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后台接口仅接受后台受众 token
|
||||||
|
if len(claims.Audience) == 0 || claims.Audience[0] != utils.JWTAudienceAdmin {
|
||||||
|
response.NoAuth("令牌无效", c)
|
||||||
|
utils.ClearToken(c)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 已登录用户被管理员禁用 需要使该用户的jwt失效 此处比较消耗性能 如果需要 请自行打开
|
// 已登录用户被管理员禁用 需要使该用户的jwt失效 此处比较消耗性能 如果需要 请自行打开
|
||||||
// 用户被删除的逻辑 需要优化 此处比较消耗性能 如果需要 请自行打开
|
// 用户被删除的逻辑 需要优化 此处比较消耗性能 如果需要 请自行打开
|
||||||
|
|
||||||
|
|||||||
67
server/model/admin/dashboard_stats.go
Normal file
67
server/model/admin/dashboard_stats.go
Normal 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"`
|
||||||
|
}
|
||||||
35
server/model/app/app_asset_transaction.go
Normal file
35
server/model/app/app_asset_transaction.go
Normal 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" }
|
||||||
30
server/model/app/app_invite_code.go
Normal file
30
server/model/app/app_invite_code.go
Normal 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"
|
||||||
|
}
|
||||||
18
server/model/app/app_login_log.go
Normal file
18
server/model/app/app_login_log.go
Normal 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" }
|
||||||
19
server/model/app/app_risk_tag.go
Normal file
19
server/model/app/app_risk_tag.go
Normal 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" }
|
||||||
|
|
||||||
23
server/model/app/app_user.go
Normal file
23
server/model/app/app_user.go
Normal 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"
|
||||||
|
}
|
||||||
16
server/model/app/app_user_activity.go
Normal file
16
server/model/app/app_user_activity.go
Normal 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" }
|
||||||
16
server/model/app/app_user_asset.go
Normal file
16
server/model/app/app_user_asset.go
Normal 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" }
|
||||||
15
server/model/app/app_user_invite_relation.go
Normal file
15
server/model/app/app_user_invite_relation.go
Normal 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"
|
||||||
|
}
|
||||||
18
server/model/app/app_user_profile.go
Normal file
18
server/model/app/app_user_profile.go
Normal 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"
|
||||||
|
}
|
||||||
19
server/model/app/app_user_risk_tag.go
Normal file
19
server/model/app/app_user_risk_tag.go
Normal 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" }
|
||||||
|
|
||||||
48
server/model/app/app_withdraw_order.go
Normal file
48
server/model/app/app_withdraw_order.go
Normal 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" }
|
||||||
|
|
||||||
8
server/model/app/request/login.go
Normal file
8
server/model/app/request/login.go
Normal 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"`
|
||||||
|
}
|
||||||
12
server/model/app/request/public_register.go
Normal file
12
server/model/app/request/public_register.go
Normal 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"`
|
||||||
|
}
|
||||||
16
server/model/app/response/register_status.go
Normal file
16
server/model/app/response/register_status.go
Normal 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"`
|
||||||
|
}
|
||||||
32
server/model/system/invite_code.go
Normal file
32
server/model/system/invite_code.go
Normal 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"
|
||||||
|
}
|
||||||
15
server/model/system/request/public_register.go
Normal file
15
server/model/system/request/public_register.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -50,13 +50,13 @@ type SetUserAuthorities struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChangeUserInfo struct {
|
type ChangeUserInfo struct {
|
||||||
ID uint `gorm:"primarykey"` // 主键ID
|
ID uint `gorm:"primarykey"` // 主键ID
|
||||||
NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称
|
NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称
|
||||||
Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号
|
Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号
|
||||||
AuthorityIds []uint `json:"authorityIds" gorm:"-"` // 角色ID
|
AuthorityIds []uint `json:"authorityIds" gorm:"-"` // 角色ID
|
||||||
Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱
|
Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱
|
||||||
HeaderImg string `json:"headerImg" gorm:"default:https://qmplusimg.henrongyi.top/gva_header.jpg;comment:用户头像"` // 用户头像
|
HeaderImg string `json:"headerImg" gorm:"default:https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y;comment:用户头像"` // 用户头像
|
||||||
Enable int `json:"enable" gorm:"comment:冻结用户"` //冻结用户
|
Enable int `json:"enable" gorm:"comment:冻结用户"` //冻结用户
|
||||||
Authorities []system.SysAuthority `json:"-" gorm:"many2many:sys_user_authority;"`
|
Authorities []system.SysAuthority `json:"-" gorm:"many2many:sys_user_authority;"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
server/model/system/response/register_status.go
Normal file
16
server/model/system/response/register_status.go
Normal 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"`
|
||||||
|
}
|
||||||
25
server/model/system/sys_param_change_log.go
Normal file
25
server/model/system/sys_param_change_log.go
Normal 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" }
|
||||||
@@ -19,18 +19,19 @@ var _ Login = new(SysUser)
|
|||||||
|
|
||||||
type SysUser struct {
|
type SysUser struct {
|
||||||
global.GVA_MODEL
|
global.GVA_MODEL
|
||||||
UUID uuid.UUID `json:"uuid" gorm:"index;comment:用户UUID"` // 用户UUID
|
UUID uuid.UUID `json:"uuid" gorm:"index;comment:用户UUID"` // 用户UUID
|
||||||
Username string `json:"userName" gorm:"index;comment:用户登录名"` // 用户登录名
|
Username string `json:"userName" gorm:"index;comment:用户登录名"` // 用户登录名
|
||||||
Password string `json:"-" gorm:"comment:用户登录密码"` // 用户登录密码
|
Password string `json:"-" gorm:"comment:用户登录密码"` // 用户登录密码
|
||||||
NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称
|
NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称
|
||||||
HeaderImg string `json:"headerImg" gorm:"default:https://qmplusimg.henrongyi.top/gva_header.jpg;comment:用户头像"` // 用户头像
|
HeaderImg string `json:"headerImg" gorm:"default:https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y;comment:用户头像"` // 用户头像
|
||||||
AuthorityId uint `json:"authorityId" gorm:"default:888;comment:用户角色ID"` // 用户角色ID
|
WelcomePhrase string `json:"welcomePhrase" gorm:"type:varchar(255);default:'';comment:欢迎词"` // 欢迎词
|
||||||
Authority SysAuthority `json:"authority" gorm:"foreignKey:AuthorityId;references:AuthorityId;comment:用户角色"` // 用户角色
|
AuthorityId uint `json:"authorityId" gorm:"default:888;comment:用户角色ID"` // 用户角色ID
|
||||||
Authorities []SysAuthority `json:"authorities" gorm:"many2many:sys_user_authority;"` // 多用户角色
|
Authority SysAuthority `json:"authority" gorm:"foreignKey:AuthorityId;references:AuthorityId;comment:用户角色"` // 用户角色
|
||||||
Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号
|
Authorities []SysAuthority `json:"authorities" gorm:"many2many:sys_user_authority;"` // 多用户角色
|
||||||
Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱
|
Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号
|
||||||
Enable int `json:"enable" gorm:"default:1;comment:用户是否被冻结 1正常 2冻结"` //用户是否被冻结 1正常 2冻结
|
Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱
|
||||||
OriginSetting common.JSONMap `json:"originSetting" form:"originSetting" gorm:"type:text;default:null;column:origin_setting;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 {
|
func (SysUser) TableName() string {
|
||||||
|
|||||||
16
server/model/system/user_invite_relation.go
Normal file
16
server/model/system/user_invite_relation.go
Normal 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"
|
||||||
|
}
|
||||||
18
server/router/app/app_auth.go
Normal file
18
server/router/app/app_auth.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
server/router/app/enter.go
Normal file
7
server/router/app/enter.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
type RouterGroup struct {
|
||||||
|
PublicAuthRouter
|
||||||
|
AppAuthRouter
|
||||||
|
InviteCodeRouter
|
||||||
|
}
|
||||||
18
server/router/app/invite_code.go
Normal file
18
server/router/app/invite_code.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
server/router/app/public_auth.go
Normal file
20
server/router/app/public_auth.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
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/common"
|
||||||
"git.echol.cn/loser/Go-Web-Template/server/router/system"
|
"git.echol.cn/loser/Go-Web-Template/server/router/system"
|
||||||
)
|
)
|
||||||
@@ -10,4 +11,5 @@ var RouterGroupApp = new(RouterGroup)
|
|||||||
type RouterGroup struct {
|
type RouterGroup struct {
|
||||||
System system.RouterGroup
|
System system.RouterGroup
|
||||||
Common common.RouterGroup
|
Common common.RouterGroup
|
||||||
|
App app.RouterGroup
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ type RouterGroup struct {
|
|||||||
JwtRouter
|
JwtRouter
|
||||||
SysRouter
|
SysRouter
|
||||||
BaseRouter
|
BaseRouter
|
||||||
|
PublicAuthRouter
|
||||||
InitRouter
|
InitRouter
|
||||||
MenuRouter
|
MenuRouter
|
||||||
UserRouter
|
UserRouter
|
||||||
|
InviteCodeRouter
|
||||||
CasbinRouter
|
CasbinRouter
|
||||||
AuthorityRouter
|
AuthorityRouter
|
||||||
DictionaryRouter
|
DictionaryRouter
|
||||||
@@ -39,4 +41,6 @@ var (
|
|||||||
dictionaryDetailApi = api.ApiGroupApp.SystemApiGroup.DictionaryDetailApi
|
dictionaryDetailApi = api.ApiGroupApp.SystemApiGroup.DictionaryDetailApi
|
||||||
mcpApi = api.ApiGroupApp.SystemApiGroup.McpApi
|
mcpApi = api.ApiGroupApp.SystemApiGroup.McpApi
|
||||||
sysErrorApi = api.ApiGroupApp.SystemApiGroup.SysErrorApi
|
sysErrorApi = api.ApiGroupApp.SystemApiGroup.SysErrorApi
|
||||||
|
publicAuthApi = api.ApiGroupApp.SystemApiGroup.PublicAuthApi
|
||||||
|
inviteCodeApi = api.ApiGroupApp.SystemApiGroup.InviteCodeApi
|
||||||
)
|
)
|
||||||
|
|||||||
19
server/router/system/invite_code.go
Normal file
19
server/router/system/invite_code.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
server/router/system/public_auth.go
Normal file
14
server/router/system/public_auth.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,9 @@ func (s *SysParamsRouter) InitSysParamsRouter(Router *gin.RouterGroup, PublicRou
|
|||||||
sysParamsRouter.PUT("updateSysParams", sysParamsApi.UpdateSysParams) // 更新参数
|
sysParamsRouter.PUT("updateSysParams", sysParamsApi.UpdateSysParams) // 更新参数
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
sysParamsRouterWithoutRecord.GET("findSysParams", sysParamsApi.FindSysParams) // 根据ID获取参数
|
sysParamsRouterWithoutRecord.GET("findSysParams", sysParamsApi.FindSysParams) // 根据ID获取参数
|
||||||
sysParamsRouterWithoutRecord.GET("getSysParamsList", sysParamsApi.GetSysParamsList) // 获取参数列表
|
sysParamsRouterWithoutRecord.GET("getSysParamsList", sysParamsApi.GetSysParamsList) // 获取参数列表
|
||||||
sysParamsRouterWithoutRecord.GET("getSysParam", sysParamsApi.GetSysParam) // 根据Key获取参数
|
sysParamsRouterWithoutRecord.GET("getSysParam", sysParamsApi.GetSysParam) // 根据Key获取参数
|
||||||
|
sysParamsRouterWithoutRecord.GET("getChangeLogList", sysParamsApi.GetSysParamChangeLogList) // 获取参数变更审计
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
server/service/app/activity.go
Normal file
40
server/service/app/activity.go
Normal 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
|
||||||
|
}
|
||||||
31
server/service/app/auth.go
Normal file
31
server/service/app/auth.go
Normal 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
|
||||||
|
}
|
||||||
7
server/service/app/enter.go
Normal file
7
server/service/app/enter.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
type ServiceGroup struct {
|
||||||
|
AppAuthService
|
||||||
|
AppRegisterService
|
||||||
|
AppInviteCodeService
|
||||||
|
}
|
||||||
132
server/service/app/invite_code.go
Normal file
132
server/service/app/invite_code.go
Normal 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
|
||||||
|
}
|
||||||
68
server/service/app/register.go
Normal file
68
server/service/app/register.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
15
server/service/app/register_config.go
Normal file
15
server/service/app/register_config.go
Normal 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() }
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
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/common"
|
||||||
"git.echol.cn/loser/Go-Web-Template/server/service/system"
|
"git.echol.cn/loser/Go-Web-Template/server/service/system"
|
||||||
)
|
)
|
||||||
@@ -10,4 +11,5 @@ var ServiceGroupApp = new(ServiceGroup)
|
|||||||
type ServiceGroup struct {
|
type ServiceGroup struct {
|
||||||
SystemServiceGroup system.ServiceGroup
|
SystemServiceGroup system.ServiceGroup
|
||||||
CommonServiceGroup common.ServiceGroup
|
CommonServiceGroup common.ServiceGroup
|
||||||
|
AppServiceGroup app.ServiceGroup
|
||||||
}
|
}
|
||||||
|
|||||||
30
server/service/system/app_online_config.go
Normal file
30
server/service/system/app_online_config.go
Normal 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
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ type ServiceGroup struct {
|
|||||||
ApiService
|
ApiService
|
||||||
MenuService
|
MenuService
|
||||||
UserService
|
UserService
|
||||||
|
InviteCodeService
|
||||||
|
PublicRegisterService
|
||||||
CasbinService
|
CasbinService
|
||||||
InitDBService
|
InitDBService
|
||||||
BaseMenuService
|
BaseMenuService
|
||||||
|
|||||||
144
server/service/system/invite_code.go
Normal file
144
server/service/system/invite_code.go
Normal 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
|
||||||
|
}
|
||||||
70
server/service/system/public_register.go
Normal file
70
server/service/system/public_register.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
84
server/service/system/register_config.go
Normal file
84
server/service/system/register_config.go
Normal 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(¶m).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()
|
||||||
|
}
|
||||||
@@ -102,10 +102,20 @@ func (apiService *ApiService) SyncApi() (newApis, deleteApis, ignoreApis []syste
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !flag {
|
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{
|
newApis = append(newApis, system.SysApi{
|
||||||
Path: cacheApis[i].Path,
|
Path: cacheApis[i].Path,
|
||||||
Description: "",
|
Description: desc,
|
||||||
ApiGroup: "",
|
ApiGroup: apiGroup,
|
||||||
Method: cacheApis[i].Method,
|
Method: cacheApis[i].Method,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
commonModel "git.echol.cn/loser/Go-Web-Template/server/model/common"
|
commonModel "git.echol.cn/loser/Go-Web-Template/server/model/common"
|
||||||
"git.echol.cn/loser/Go-Web-Template/server/service/system"
|
"git.echol.cn/loser/Go-Web-Template/server/service/system"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package system
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.echol.cn/loser/Go-Web-Template/server/global"
|
||||||
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
|
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/service/system"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -13,9 +15,81 @@ type initApi struct{}
|
|||||||
|
|
||||||
const initOrderApi = system.InitOrderSystem + 1
|
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
|
// auto run
|
||||||
func init() {
|
func init() {
|
||||||
system.RegisterInit(initOrderApi, &initApi{})
|
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 {
|
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: "POST", Path: "/user/resetPassword", Description: "重置用户密码"},
|
||||||
{ApiGroup: "系统用户", Method: "PUT", Path: "/user/setSelfSetting", Description: "用户界面配置"},
|
{ApiGroup: "系统用户", Method: "PUT", Path: "/user/setSelfSetting", Description: "用户界面配置"},
|
||||||
|
|
||||||
{ApiGroup: "api", Method: "POST", Path: "/api/createApi", 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/deleteApi", Description: "删除Api"},
|
||||||
{ApiGroup: "api", Method: "POST", Path: "/api/updateApi", 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/getApiList", Description: "获取api列表"},
|
||||||
{ApiGroup: "api", Method: "POST", Path: "/api/getAllApis", Description: "获取所有api"},
|
{ApiGroup: "API管理", Method: "POST", Path: "/api/getAllApis", Description: "获取所有api"},
|
||||||
{ApiGroup: "api", Method: "POST", Path: "/api/getApiById", Description: "获取api详细信息"},
|
{ApiGroup: "API管理", Method: "POST", Path: "/api/getApiById", Description: "获取api详细信息"},
|
||||||
{ApiGroup: "api", Method: "DELETE", Path: "/api/deleteApisByIds", 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/syncApi", Description: "获取待同步API"},
|
||||||
{ApiGroup: "api", Method: "GET", Path: "/api/getApiGroups", Description: "获取路由组"},
|
{ApiGroup: "API管理", Method: "GET", Path: "/api/getApiGroups", Description: "获取路由组"},
|
||||||
{ApiGroup: "api", Method: "POST", Path: "/api/enterSyncApi", Description: "确认同步API"},
|
{ApiGroup: "API管理", Method: "POST", Path: "/api/enterSyncApi", Description: "确认同步API"},
|
||||||
{ApiGroup: "api", Method: "POST", Path: "/api/ignoreApi", 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/copyAuthority", Description: "拷贝角色"},
|
||||||
{ApiGroup: "角色", Method: "POST", Path: "/authority/createAuthority", 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/getServerInfo", Description: "获取服务器信息"},
|
||||||
{ApiGroup: "系统服务", Method: "POST", Path: "/system/getSystemConfig", Description: "获取配置文件内容"},
|
{ApiGroup: "系统服务", Method: "POST", Path: "/system/getSystemConfig", Description: "获取配置文件内容"},
|
||||||
{ApiGroup: "系统服务", Method: "POST", Path: "/system/setSystemConfig", 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: "GET", Path: "/mcp/status", Description: "获取 MCP 独立服务状态"},
|
||||||
{ApiGroup: "MCP管理", Method: "POST", Path: "/mcp/start", 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/findSysParams", Description: "根据ID获取参数"},
|
||||||
{ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getSysParamsList", Description: "获取参数列表"},
|
{ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getSysParamsList", Description: "获取参数列表"},
|
||||||
{ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getSysParam", Description: "获取参数列表"},
|
{ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getSysParam", Description: "获取参数列表"},
|
||||||
|
{ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getChangeLogList", Description: "获取参数变更审计"},
|
||||||
{ApiGroup: "媒体库分类", Method: "GET", Path: "/attachmentCategory/getCategoryList", Description: "分类列表"},
|
{ApiGroup: "媒体库分类", Method: "GET", Path: "/attachmentCategory/getCategoryList", Description: "分类列表"},
|
||||||
{ApiGroup: "媒体库分类", Method: "POST", Path: "/attachmentCategory/addCategory", Description: "添加/编辑分类"},
|
{ApiGroup: "媒体库分类", Method: "POST", Path: "/attachmentCategory/addCategory", Description: "添加/编辑分类"},
|
||||||
{ApiGroup: "媒体库分类", Method: "POST", Path: "/attachmentCategory/deleteCategory", 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 {
|
if err := db.Create(&entities).Error; err != nil {
|
||||||
return ctx, errors.Wrap(err, sysModel.SysApi{}.TableName()+"表数据初始化失败!")
|
return ctx, errors.Wrap(err, sysModel.SysApi{}.TableName()+"表数据初始化失败!")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/getSystemConfig", V2: "POST"},
|
||||||
{Ptype: "p", V0: "888", V1: "/system/setSystemConfig", 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: "/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/status", V2: "GET"},
|
||||||
{Ptype: "p", V0: "888", V1: "/mcp/start", V2: "POST"},
|
{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/findSysParams", V2: "GET"},
|
||||||
{Ptype: "p", V0: "888", V1: "/sysParams/getSysParamsList", 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/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/getCategoryList", V2: "GET"},
|
||||||
{Ptype: "p", V0: "888", V1: "/attachmentCategory/addCategory", V2: "POST"},
|
{Ptype: "p", V0: "888", V1: "/attachmentCategory/addCategory", V2: "POST"},
|
||||||
{Ptype: "p", V0: "888", V1: "/attachmentCategory/deleteCategory", 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/getSystemConfig", V2: "POST"},
|
||||||
{Ptype: "p", V0: "8881", V1: "/system/setSystemConfig", 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: "/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: "/user/admin_register", V2: "POST"},
|
||||||
{Ptype: "p", V0: "9528", V1: "/api/createApi", 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/tools", V2: "GET"},
|
||||||
{Ptype: "p", V0: "9528", V1: "/mcp/test", V2: "POST"},
|
{Ptype: "p", V0: "9528", V1: "/mcp/test", V2: "POST"},
|
||||||
{Ptype: "p", V0: "9528", V1: "/mcp/createTool", 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: "/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 {
|
if err := db.Create(&entities).Error; err != nil {
|
||||||
return ctx, errors.Wrap(err, "Casbin 表 ("+i.InitializerName()+") 数据初始化失败!")
|
return ctx, errors.Wrap(err, "Casbin 表 ("+i.InitializerName()+") 数据初始化失败!")
|
||||||
|
|||||||
223
server/source/system/menu_repair.go
Normal file
223
server/source/system/menu_repair.go
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ package system
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
|
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/service/system"
|
||||||
"git.echol.cn/loser/Go-Web-Template/server/utils"
|
"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(),
|
UUID: uuid.New(),
|
||||||
Username: "admin",
|
Username: "admin",
|
||||||
Password: adminPassword,
|
Password: adminPassword,
|
||||||
NickName: "Mr.奇淼",
|
NickName: "超级管理员",
|
||||||
HeaderImg: "https://qmplusimg.henrongyi.top/gva_header.jpg",
|
HeaderImg: "https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y",
|
||||||
AuthorityId: 888,
|
AuthorityId: 888,
|
||||||
Phone: "17611111111",
|
Phone: "13888888888",
|
||||||
Email: "333333333@qq.com",
|
Email: "admin@example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
UUID: uuid.New(),
|
UUID: uuid.New(),
|
||||||
Username: "a303176530",
|
Username: "JohnDoe",
|
||||||
Password: password,
|
Password: password,
|
||||||
NickName: "用户1",
|
NickName: "测试用户",
|
||||||
HeaderImg: "https://qmplusimg.henrongyi.top/1572075907logo.png",
|
HeaderImg: "https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y",
|
||||||
AuthorityId: 9528,
|
AuthorityId: 9528,
|
||||||
Phone: "17611111111",
|
Phone: "13999999999",
|
||||||
Email: "333333333@qq.com"},
|
Email: "test@example.com"},
|
||||||
}
|
}
|
||||||
if err = db.Create(&entities).Error; err != nil {
|
if err = db.Create(&entities).Error; err != nil {
|
||||||
return ctx, errors.Wrap(err, sysModel.SysUser{}.TableName()+"表数据初始化失败!")
|
return ctx, errors.Wrap(err, sysModel.SysUser{}.TableName()+"表数据初始化失败!")
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func GetUserName(c *gin.Context) string {
|
|||||||
|
|
||||||
func LoginToken(user system.Login) (token string, claims systemReq.CustomClaims, err error) {
|
func LoginToken(user system.Login) (token string, claims systemReq.CustomClaims, err error) {
|
||||||
j := NewJWT()
|
j := NewJWT()
|
||||||
claims = j.CreateClaims(systemReq.BaseClaims{
|
claims = j.CreateAdminClaims(systemReq.BaseClaims{
|
||||||
UUID: user.GetUUID(),
|
UUID: user.GetUUID(),
|
||||||
ID: user.GetUserId(),
|
ID: user.GetUserId(),
|
||||||
NickName: user.GetNickname(),
|
NickName: user.GetNickname(),
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ type JWT struct {
|
|||||||
SigningKey []byte
|
SigningKey []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
JWTAudienceAdmin = "GVA_ADMIN"
|
||||||
|
JWTAudienceApp = "GVA_APP"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
TokenValid = errors.New("未知错误")
|
TokenValid = errors.New("未知错误")
|
||||||
TokenExpired = errors.New("token已过期")
|
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)
|
bf, _ := ParseDuration(global.GVA_CONFIG.JWT.BufferTime)
|
||||||
ep, _ := ParseDuration(global.GVA_CONFIG.JWT.ExpiresTime)
|
ep, _ := ParseDuration(global.GVA_CONFIG.JWT.ExpiresTime)
|
||||||
claims := request.CustomClaims{
|
claims := request.CustomClaims{
|
||||||
BaseClaims: baseClaims,
|
BaseClaims: baseClaims,
|
||||||
BufferTime: int64(bf / time.Second), // 缓冲时间1天 缓冲时间内会获得新的token刷新令牌 此时一个用户会存在两个有效令牌 但是前端只留一个 另一个会丢失
|
BufferTime: int64(bf / time.Second), // 缓冲时间1天 缓冲时间内会获得新的token刷新令牌 此时一个用户会存在两个有效令牌 但是前端只留一个 另一个会丢失
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Audience: jwt.ClaimStrings{"GVA"}, // 受众
|
Audience: jwt.ClaimStrings{aud}, // 受众
|
||||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1000)), // 签名生效时间
|
NotBefore: jwt.NewNumericDate(time.Now().Add(-1000)), // 签名生效时间
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ep)), // 过期时间 7天 配置文件
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ep)), // 过期时间 7天 配置文件
|
||||||
Issuer: global.GVA_CONFIG.JWT.Issuer, // 签名的发行者
|
Issuer: global.GVA_CONFIG.JWT.Issuer, // 签名的发行者
|
||||||
@@ -45,6 +50,14 @@ func (j *JWT) CreateClaims(baseClaims request.BaseClaims) request.CustomClaims {
|
|||||||
return claims
|
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
|
// CreateToken 创建一个token
|
||||||
func (j *JWT) CreateToken(claims request.CustomClaims) (string, error) {
|
func (j *JWT) CreateToken(claims request.CustomClaims) (string, error) {
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|||||||
1317
web-admin/package-lock.json
generated
1317
web-admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"ua-parser-js": "^2.0.9",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
BIN
web-admin/src/assets/avatar_user.jpg
Normal file
BIN
web-admin/src/assets/avatar_user.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,393 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Button, Card, Col, DatePicker, Descriptions, Divider, Drawer, Form, Input, Row, Select, Space, Table, Tag, Tooltip, Typography } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { appAssetTransactionsAdminApi } from '@/lib/api'
|
||||||
|
import { formatDate } from '@/lib/date'
|
||||||
|
import type { AppAssetTransactionRecord } from '@/types/system'
|
||||||
|
|
||||||
|
type Search = {
|
||||||
|
username?: string
|
||||||
|
userId?: string
|
||||||
|
assetType?: string
|
||||||
|
rechargeType?: string
|
||||||
|
operatorUserId?: string
|
||||||
|
keyword?: string
|
||||||
|
range?: [dayjs.Dayjs, dayjs.Dayjs]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLiToYuan(li?: number) {
|
||||||
|
const n = typeof li === 'number' && Number.isFinite(li) ? li : 0
|
||||||
|
return (n / 1000).toFixed(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(input?: string) {
|
||||||
|
const raw = (input ?? '').trim()
|
||||||
|
if (!raw) return undefined
|
||||||
|
const n = Number(raw)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return undefined
|
||||||
|
return Math.floor(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAssetType(assetType?: string) {
|
||||||
|
if (assetType === 'account') return '账户余额'
|
||||||
|
if (assetType === 'game_coin') return '游戏币'
|
||||||
|
return assetType ? String(assetType) : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRechargeType(rechargeType?: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
offline: '线下充值',
|
||||||
|
online_not_arrived: '线上未到账',
|
||||||
|
activity_subsidy: '活动补贴',
|
||||||
|
manual_adjustment: '人工调整',
|
||||||
|
}
|
||||||
|
return rechargeType ? map[String(rechargeType)] ?? String(rechargeType) : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppAssetTransactionsPage() {
|
||||||
|
const [form] = Form.useForm<Search>()
|
||||||
|
const [rows, setRows] = useState<AppAssetTransactionRecord[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
const [drawerLoading, setDrawerLoading] = useState(false)
|
||||||
|
const [drawerRecord, setDrawerRecord] = useState<AppAssetTransactionRecord | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
const range = values.range
|
||||||
|
const startAt = range?.[0]?.toISOString()
|
||||||
|
const endAt = range?.[1]?.toISOString()
|
||||||
|
const res = await appAssetTransactionsAdminApi.getList({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
userId: parsePositiveInt(values.userId),
|
||||||
|
username: values.username?.trim() || undefined,
|
||||||
|
assetType: values.assetType,
|
||||||
|
rechargeType: values.rechargeType,
|
||||||
|
operatorUserId: parsePositiveInt(values.operatorUserId),
|
||||||
|
keyword: values.keyword?.trim() || undefined,
|
||||||
|
...(startAt ? { startAt } : {}),
|
||||||
|
...(endAt ? { endAt } : {}),
|
||||||
|
})
|
||||||
|
setRows(res.data.list)
|
||||||
|
setTotal(res.data.total)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [form, page, pageSize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const openDetail = async (record: AppAssetTransactionRecord) => {
|
||||||
|
setDrawerOpen(true)
|
||||||
|
setDrawerLoading(true)
|
||||||
|
try {
|
||||||
|
const detail = await appAssetTransactionsAdminApi.getById(record.id)
|
||||||
|
setDrawerRecord(detail.data)
|
||||||
|
} finally {
|
||||||
|
setDrawerLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<AppAssetTransactionRecord> = useMemo(
|
||||||
|
() => [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||||
|
{ title: '玩家ID', dataIndex: 'appUserId', width: 100 },
|
||||||
|
{ title: '用户名', dataIndex: 'username', width: 180 },
|
||||||
|
{
|
||||||
|
title: '资产类型',
|
||||||
|
dataIndex: 'assetType',
|
||||||
|
width: 120,
|
||||||
|
render: (v) => formatAssetType(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '变动类型',
|
||||||
|
dataIndex: 'rechargeType',
|
||||||
|
width: 160,
|
||||||
|
render: (v) => formatRechargeType(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '变动(元)',
|
||||||
|
dataIndex: 'deltaLi',
|
||||||
|
width: 120,
|
||||||
|
render: (v: number) => {
|
||||||
|
const yuan = formatLiToYuan(v)
|
||||||
|
const color = v > 0 ? '#1677ff' : v < 0 ? '#cf1322' : undefined
|
||||||
|
return <span style={{ color }}>{yuan}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '变动前(元)',
|
||||||
|
dataIndex: 'beforeLi',
|
||||||
|
width: 120,
|
||||||
|
render: (v: number) => formatLiToYuan(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '变动后(元)',
|
||||||
|
dataIndex: 'afterLi',
|
||||||
|
width: 120,
|
||||||
|
render: (v: number) => formatLiToYuan(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '原因',
|
||||||
|
dataIndex: 'reason',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string) => (v ? <Tooltip title={v}>{v}</Tooltip> : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作人',
|
||||||
|
width: 160,
|
||||||
|
render: (_, r) => (r.operatorName ? `${r.operatorName}(#${r.operatorUserId})` : `#${r.operatorUserId}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
width: 190,
|
||||||
|
render: (_, r) => formatDate(r.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 110,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, r) => (
|
||||||
|
<Button type="link" style={{ padding: 0 }} onClick={() => openDetail(r)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={() => {
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 8]}>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||||
|
<Form.Item name="userId" label="玩家ID" style={{ marginBottom: 0 }}>
|
||||||
|
<Input allowClear placeholder="输入玩家ID" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||||
|
<Form.Item name="username" label="用户名" style={{ marginBottom: 0 }}>
|
||||||
|
<Input allowClear placeholder="输入用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||||
|
<Form.Item name="assetType" label="资产类型" style={{ marginBottom: 0 }}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部"
|
||||||
|
options={[
|
||||||
|
{ label: '账户余额', value: 'account' },
|
||||||
|
{ label: '游戏币', value: 'game_coin' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||||
|
<Form.Item name="rechargeType" label="变动类型" style={{ marginBottom: 0 }}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部"
|
||||||
|
options={[
|
||||||
|
{ label: '线下充值', value: 'offline' },
|
||||||
|
{ label: '线上未到账', value: 'online_not_arrived' },
|
||||||
|
{ label: '活动补贴', value: 'activity_subsidy' },
|
||||||
|
{ label: '人工调整', value: 'manual_adjustment' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||||
|
<Form.Item name="operatorUserId" label="操作人ID" style={{ marginBottom: 0 }}>
|
||||||
|
<Input allowClear placeholder="输入操作人ID" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||||
|
<Form.Item name="keyword" label="原因关键字" style={{ marginBottom: 0 }}>
|
||||||
|
<Input allowClear placeholder="例如:补贴/冲正/误操作" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12} xl={8}>
|
||||||
|
<Form.Item name="range" label="时间范围" style={{ marginBottom: 0 }}>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
showTime
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
presets={[
|
||||||
|
{ label: '近24小时', value: [dayjs().subtract(24, 'hour'), dayjs()] },
|
||||||
|
{ label: '近7天', value: [dayjs().subtract(7, 'day'), dayjs()] },
|
||||||
|
{ label: '近30天', value: [dayjs().subtract(30, 'day'), dayjs()] },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12} xl={4}>
|
||||||
|
<Form.Item label=" " style={{ marginBottom: 0 }}>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields()
|
||||||
|
setPage(1)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
scroll={{ x: 1600 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={drawerOpen}
|
||||||
|
width={560}
|
||||||
|
title={drawerRecord ? `流水详情 #${drawerRecord.id}` : '流水详情'}
|
||||||
|
onClose={() => {
|
||||||
|
setDrawerOpen(false)
|
||||||
|
setDrawerRecord(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{drawerLoading ? (
|
||||||
|
<Typography.Paragraph>加载中...</Typography.Paragraph>
|
||||||
|
) : drawerRecord ? (
|
||||||
|
(() => {
|
||||||
|
const isPlus = drawerRecord.deltaLi >= 0
|
||||||
|
const deltaYuan = formatLiToYuan(drawerRecord.deltaLi)
|
||||||
|
const beforeYuan = formatLiToYuan(drawerRecord.beforeLi)
|
||||||
|
const afterYuan = formatLiToYuan(drawerRecord.afterLi)
|
||||||
|
const deltaColor = drawerRecord.deltaLi > 0 ? '#1677ff' : drawerRecord.deltaLi < 0 ? '#cf1322' : undefined
|
||||||
|
const operator = drawerRecord.operatorName ? `${drawerRecord.operatorName}(#${drawerRecord.operatorUserId})` : `#${drawerRecord.operatorUserId}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<Card size="small" styles={{ body: { padding: 12 } }}>
|
||||||
|
<Row gutter={[12, 10]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Tag color="default">玩家</Tag>
|
||||||
|
<Typography.Text strong>
|
||||||
|
{drawerRecord.username}(#{drawerRecord.appUserId})
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Row gutter={[12, 10]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text type="secondary">资产类型</Typography.Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Typography.Text>{formatAssetType(drawerRecord.assetType)}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text type="secondary">变动类型</Typography.Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Typography.Text>{formatRechargeType(drawerRecord.rechargeType)}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text type="secondary">变动金额</Typography.Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Typography.Text strong style={{ color: deltaColor, fontSize: 18 }}>
|
||||||
|
{isPlus ? '+' : ''}
|
||||||
|
{deltaYuan}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary"> 元</Typography.Text>
|
||||||
|
<Tag color={drawerRecord.deltaLi > 0 ? 'blue' : drawerRecord.deltaLi < 0 ? 'red' : 'default'} style={{ marginInlineStart: 8 }}>
|
||||||
|
{drawerRecord.deltaLi > 0 ? '增加' : drawerRecord.deltaLi < 0 ? '减少' : '不变'}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text type="secondary">变动前 → 变动后</Typography.Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Typography.Text>
|
||||||
|
{beforeYuan} → {afterYuan}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">(元)</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '4px 0' }} />
|
||||||
|
|
||||||
|
<Descriptions
|
||||||
|
size="small"
|
||||||
|
column={1}
|
||||||
|
bordered
|
||||||
|
styles={{ label: { width: 120, color: 'rgba(0,0,0,0.45)' } }}
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="原因">
|
||||||
|
<Typography.Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{drawerRecord.reason || '-'}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="操作人">{operator}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{formatDate(drawerRecord.createdAt)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="单位说明">
|
||||||
|
<Tag style={{ marginInlineEnd: 0 }}>1 元 = 1000 厘</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<Typography.Paragraph>暂无数据</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
207
web-admin/src/features/appLoginLogs/AppLoginLogsPage.tsx
Normal file
207
web-admin/src/features/appLoginLogs/AppLoginLogsPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Button, Card, DatePicker, Form, Input, Select, Space, Table, Tag, Tooltip, Typography } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { UAParser } from 'ua-parser-js'
|
||||||
|
import { appLoginLogAdminApi } from '@/lib/api'
|
||||||
|
import { formatDate } from '@/lib/date'
|
||||||
|
import type { AppLoginLogRecord } from '@/types/system'
|
||||||
|
|
||||||
|
type Search = {
|
||||||
|
username?: string
|
||||||
|
ip?: string
|
||||||
|
status?: boolean
|
||||||
|
range?: [dayjs.Dayjs, dayjs.Dayjs]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClientInfo(rawUa?: string) {
|
||||||
|
const ua = (rawUa ?? '').trim()
|
||||||
|
if (!ua) return { summary: '-', detail: '' }
|
||||||
|
const parsed = new UAParser(ua).getResult()
|
||||||
|
const browser = [parsed.browser.name, parsed.browser.version].filter(Boolean).join(' ')
|
||||||
|
const os = [parsed.os.name, parsed.os.version].filter(Boolean).join(' ')
|
||||||
|
const device = [parsed.device.vendor, parsed.device.model, parsed.device.type].filter(Boolean).join(' ')
|
||||||
|
const summary = [browser || '-', os || '-', device].filter(Boolean).join(' / ')
|
||||||
|
return { summary, detail: ua }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppLoginLogsPage() {
|
||||||
|
const [form] = Form.useForm<Search>()
|
||||||
|
const [rows, setRows] = useState<AppLoginLogRecord[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
const range = values.range
|
||||||
|
const startAt = range?.[0]?.toISOString()
|
||||||
|
const endAt = range?.[1]?.toISOString()
|
||||||
|
const res = await appLoginLogAdminApi.getList({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
username: values.username,
|
||||||
|
ip: values.ip,
|
||||||
|
...(typeof values.status === 'boolean' ? { status: values.status } : {}),
|
||||||
|
...(startAt ? { startAt } : {}),
|
||||||
|
...(endAt ? { endAt } : {}),
|
||||||
|
})
|
||||||
|
setRows(res.data.list)
|
||||||
|
setTotal(res.data.total)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [form, page, pageSize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const columns: ColumnsType<AppLoginLogRecord> = useMemo(
|
||||||
|
() => [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||||
|
{ title: '用户名', dataIndex: 'username', width: 180 },
|
||||||
|
{ title: 'IP', dataIndex: 'ip', width: 160 },
|
||||||
|
{
|
||||||
|
title: '参考地址',
|
||||||
|
dataIndex: 'referer',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v?: string) => (v ? <Tooltip title={v}>{v}</Tooltip> : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '客户端信息',
|
||||||
|
dataIndex: 'userAgent',
|
||||||
|
ellipsis: true,
|
||||||
|
width: 280,
|
||||||
|
render: (v?: string) => {
|
||||||
|
const info = formatClientInfo(v)
|
||||||
|
return info.detail ? (
|
||||||
|
<Tooltip title={info.detail}>
|
||||||
|
<span>{info.summary}</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span>{info.summary}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) => <Tag color={r.status ? 'green' : 'red'}>{r.status ? '成功' : '失败'}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '失败原因',
|
||||||
|
dataIndex: 'errorMessage',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string | undefined, r) => (r.status ? '-' : v || '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '登录时间',
|
||||||
|
width: 190,
|
||||||
|
render: (_, r) => formatDate(r.createdAt),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||||
|
登录日志
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||||
|
用于审计玩家登录行为(含失败原因、IP、参考地址与客户端信息)。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
onFinish={() => {
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="username" label="用户名">
|
||||||
|
<Input allowClear placeholder="输入用户名" style={{ width: 220 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="ip" label="IP">
|
||||||
|
<Input allowClear placeholder="输入IP" style={{ width: 220 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ label: '成功', value: true },
|
||||||
|
{ label: '失败', value: false },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="range" label="时间范围">
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
showTime
|
||||||
|
allowClear
|
||||||
|
style={{ width: 360 }}
|
||||||
|
presets={[
|
||||||
|
{ label: '近1小时', value: [dayjs().subtract(1, 'hour'), dayjs()] },
|
||||||
|
{ label: '近24小时', value: [dayjs().subtract(24, 'hour'), dayjs()] },
|
||||||
|
{ label: '近7天', value: [dayjs().subtract(7, 'day'), dayjs()] },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields()
|
||||||
|
setPage(1)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
scroll={{ x: 1400 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
576
web-admin/src/features/appPlayers/AppPlayersPage.tsx
Normal file
576
web-admin/src/features/appPlayers/AppPlayersPage.tsx
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Button, Card, Descriptions, Drawer, Form, Input, Modal, Select, Space, Switch, Table, Tag, Typography, message } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { appPlayersAdminApi } from '@/lib/api'
|
||||||
|
import type { AppPlayerOverview, AppPlayerRecord } from '@/types/system'
|
||||||
|
|
||||||
|
type Search = {
|
||||||
|
keyword?: string
|
||||||
|
isOnline?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditFormValues = {
|
||||||
|
enable: boolean
|
||||||
|
welcomePhrase?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetPasswordValues = {
|
||||||
|
newPassword: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RechargeValues = {
|
||||||
|
rechargeType: 'offline' | 'online_not_arrived' | 'activity_subsidy' | 'manual_adjustment'
|
||||||
|
assetType: 'account' | 'game_coin'
|
||||||
|
deltaYuan: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLi(li?: number) {
|
||||||
|
const n = typeof li === 'number' && Number.isFinite(li) ? li : 0
|
||||||
|
return (n / 1000).toFixed(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDeltaLiFromYuan(input: string) {
|
||||||
|
const trimmed = (input || '').trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
const n = Number(trimmed)
|
||||||
|
if (!Number.isFinite(n)) return null
|
||||||
|
return Math.round(n * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppPlayersPage() {
|
||||||
|
const [form] = Form.useForm<Search>()
|
||||||
|
const [createForm] = Form.useForm()
|
||||||
|
const [editForm] = Form.useForm<EditFormValues>()
|
||||||
|
const [resetPasswordForm] = Form.useForm<ResetPasswordValues>()
|
||||||
|
const [rechargeForm] = Form.useForm<RechargeValues>()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [items, setItems] = useState<AppPlayerRecord[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [editingRecord, setEditingRecord] = useState<AppPlayerRecord | null>(null)
|
||||||
|
const [resetOpen, setResetOpen] = useState(false)
|
||||||
|
const [resetting, setResetting] = useState(false)
|
||||||
|
const [resetRecord, setResetRecord] = useState<AppPlayerRecord | null>(null)
|
||||||
|
const [rechargeOpen, setRechargeOpen] = useState(false)
|
||||||
|
const [recharging, setRecharging] = useState(false)
|
||||||
|
const [rechargeRecord, setRechargeRecord] = useState<AppPlayerRecord | null>(null)
|
||||||
|
const rechargeType = Form.useWatch('rechargeType', rechargeForm)
|
||||||
|
|
||||||
|
const [overviewOpen, setOverviewOpen] = useState(false)
|
||||||
|
const [overviewLoading, setOverviewLoading] = useState(false)
|
||||||
|
const [overview, setOverview] = useState<AppPlayerOverview | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
const res = await appPlayersAdminApi.getList({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
setItems(res.data.list)
|
||||||
|
setTotal(res.data.total)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [form, page, pageSize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const columns: ColumnsType<AppPlayerRecord> = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||||
|
{ title: '用户名', dataIndex: 'username', width: 180 },
|
||||||
|
{ title: '昵称', dataIndex: 'nickName', width: 160 },
|
||||||
|
{
|
||||||
|
title: '账户余额(元)',
|
||||||
|
width: 140,
|
||||||
|
render: (_, record) => <span>{formatLi(record.accountBalanceLi)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '游戏币(元)',
|
||||||
|
width: 140,
|
||||||
|
render: (_, record) => <span>{formatLi(record.gameCoinBalanceLi)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '在线',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Tag color={record.isOnline ? 'green' : 'default'}>{record.isOnline ? '在线' : '离线'}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 190 },
|
||||||
|
{ title: '最后活跃', dataIndex: 'lastActiveAt', width: 190 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 110,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Switch
|
||||||
|
checked={record.enable === 1}
|
||||||
|
checkedChildren="启用"
|
||||||
|
unCheckedChildren="封禁"
|
||||||
|
onChange={async () => {
|
||||||
|
await appPlayersAdminApi.toggleEnable(record.id)
|
||||||
|
message.success(record.enable === 1 ? '已封禁' : '已解封')
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
fixed: 'right',
|
||||||
|
width: 370,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size={8}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
style={{ padding: 0, color: '#722ed1' }}
|
||||||
|
onClick={async () => {
|
||||||
|
setOverviewOpen(true)
|
||||||
|
setOverviewLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await appPlayersAdminApi.getOverview(record.id)
|
||||||
|
setOverview(res.data)
|
||||||
|
} finally {
|
||||||
|
setOverviewLoading(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
style={{ padding: 0, color: '#13c2c2' }}
|
||||||
|
onClick={() => {
|
||||||
|
setRechargeRecord(record)
|
||||||
|
rechargeForm.resetFields()
|
||||||
|
rechargeForm.setFieldsValue({ rechargeType: 'offline', assetType: 'account' })
|
||||||
|
setRechargeOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
充值
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
style={{ padding: 0, color: '#1677ff' }}
|
||||||
|
onClick={async () => {
|
||||||
|
setEditingRecord(record)
|
||||||
|
const detail = await appPlayersAdminApi.getById(record.id)
|
||||||
|
editForm.resetFields()
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
enable: detail.data.enable === 1,
|
||||||
|
welcomePhrase: detail.data.welcomePhrase ?? '',
|
||||||
|
})
|
||||||
|
setEditOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
style={{ padding: 0, color: '#fa8c16' }}
|
||||||
|
onClick={() => {
|
||||||
|
setResetRecord(record)
|
||||||
|
resetPasswordForm.resetFields()
|
||||||
|
setResetOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置密码
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
style={{ padding: 0, color: '#cf1322' }}
|
||||||
|
onClick={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除该玩家?',
|
||||||
|
content: `用户名:${record.username}(软删除)`,
|
||||||
|
okText: '删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
await appPlayersAdminApi.delete(record.id)
|
||||||
|
message.success('已删除')
|
||||||
|
load()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
onFinish={() => {
|
||||||
|
setPage(1)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<Space wrap>
|
||||||
|
<Form.Item name="keyword">
|
||||||
|
<Input placeholder="输入用户名关键字" allowClear style={{ width: 320 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="isOnline">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="在线状态"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ label: '在线', value: true },
|
||||||
|
{ label: '离线', value: false },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields()
|
||||||
|
setPage(1)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
createForm.resetFields()
|
||||||
|
createForm.setFieldsValue({ enable: true })
|
||||||
|
setCreateOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建玩家
|
||||||
|
</Button>
|
||||||
|
<Button onClick={load}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={items}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={createOpen}
|
||||||
|
title="新建玩家"
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
confirmLoading={creating}
|
||||||
|
onOk={async () => {
|
||||||
|
const values = await createForm.validateFields()
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
await appPlayersAdminApi.create({
|
||||||
|
username: values.username,
|
||||||
|
password: values.password,
|
||||||
|
enable: values.enable,
|
||||||
|
nickName: values.nickName,
|
||||||
|
welcomePhrase: values.welcomePhrase,
|
||||||
|
})
|
||||||
|
message.success('玩家已创建')
|
||||||
|
setCreateOpen(false)
|
||||||
|
load()
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={createForm} layout="vertical">
|
||||||
|
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3, message: '至少 3 位' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="password" label="登录密码" rules={[{ required: true, min: 6, message: '至少 6 位' }]}>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="nickName" label="昵称">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="welcomePhrase" label="安全欢迎词">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enable" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={editOpen}
|
||||||
|
title="编辑玩家"
|
||||||
|
onCancel={() => {
|
||||||
|
setEditOpen(false)
|
||||||
|
setEditingRecord(null)
|
||||||
|
}}
|
||||||
|
confirmLoading={editing}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!editingRecord) return
|
||||||
|
const values = await editForm.validateFields()
|
||||||
|
setEditing(true)
|
||||||
|
try {
|
||||||
|
await appPlayersAdminApi.update(editingRecord.id, {
|
||||||
|
enable: values.enable,
|
||||||
|
welcomePhrase: values.welcomePhrase ?? '',
|
||||||
|
})
|
||||||
|
message.success('已更新')
|
||||||
|
setEditOpen(false)
|
||||||
|
setEditingRecord(null)
|
||||||
|
load()
|
||||||
|
} finally {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={editForm} layout="vertical">
|
||||||
|
<Form.Item name="enable" label="启用" valuePropName="checked" rules={[{ required: true }]}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="welcomePhrase" label="安全欢迎词">
|
||||||
|
<Input.TextArea rows={3} allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={resetOpen}
|
||||||
|
title="重置密码"
|
||||||
|
onCancel={() => {
|
||||||
|
setResetOpen(false)
|
||||||
|
setResetRecord(null)
|
||||||
|
}}
|
||||||
|
confirmLoading={resetting}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!resetRecord) return
|
||||||
|
const values = await resetPasswordForm.validateFields()
|
||||||
|
setResetting(true)
|
||||||
|
try {
|
||||||
|
await appPlayersAdminApi.resetPassword(resetRecord.id, { newPassword: values.newPassword })
|
||||||
|
message.success('密码已重置')
|
||||||
|
setResetOpen(false)
|
||||||
|
setResetRecord(null)
|
||||||
|
} finally {
|
||||||
|
setResetting(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={resetPasswordForm} layout="vertical">
|
||||||
|
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '至少 6 位' }]}>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={rechargeOpen}
|
||||||
|
title={`充值${rechargeRecord ? ` · ${rechargeRecord.username}` : ''}`}
|
||||||
|
onCancel={() => {
|
||||||
|
setRechargeOpen(false)
|
||||||
|
setRechargeRecord(null)
|
||||||
|
}}
|
||||||
|
confirmLoading={recharging}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!rechargeRecord) return
|
||||||
|
const values = await rechargeForm.validateFields()
|
||||||
|
const deltaLi = parseDeltaLiFromYuan(values.deltaYuan)
|
||||||
|
if (deltaLi === null || deltaLi === 0) {
|
||||||
|
message.error('请输入有效的变动金额(支持正负,最多 3 位小数)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRecharging(true)
|
||||||
|
try {
|
||||||
|
await appPlayersAdminApi.recharge(rechargeRecord.id, {
|
||||||
|
assetType: values.assetType,
|
||||||
|
rechargeType: values.rechargeType,
|
||||||
|
deltaLi,
|
||||||
|
reason: values.reason.trim(),
|
||||||
|
})
|
||||||
|
message.success('充值已提交')
|
||||||
|
setRechargeOpen(false)
|
||||||
|
setRechargeRecord(null)
|
||||||
|
load()
|
||||||
|
} finally {
|
||||||
|
setRecharging(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={rechargeForm}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ rechargeType: 'offline', assetType: 'account' }}
|
||||||
|
onValuesChange={(changed) => {
|
||||||
|
if (changed.rechargeType === 'activity_subsidy') {
|
||||||
|
rechargeForm.setFieldsValue({ assetType: 'game_coin' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="rechargeType" label="充值类型" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ label: '线下充值', value: 'offline' },
|
||||||
|
{ label: '线上充值未到账', value: 'online_not_arrived' },
|
||||||
|
{ label: '活动补贴(仅游戏币)', value: 'activity_subsidy' },
|
||||||
|
{ label: '其他/人工调整', value: 'manual_adjustment' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="assetType" label="资产类型" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
disabled={rechargeType === 'activity_subsidy'}
|
||||||
|
options={[
|
||||||
|
{ label: '账户余额', value: 'account' },
|
||||||
|
{ label: '游戏币余额', value: 'game_coin' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="deltaYuan"
|
||||||
|
label="变动金额(元)"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入变动金额' },
|
||||||
|
{
|
||||||
|
validator: (_, value) => {
|
||||||
|
const parsed = parseDeltaLiFromYuan(String(value ?? ''))
|
||||||
|
return parsed === null ? Promise.reject(new Error('请输入数字(支持正负与 3 位小数)')) : Promise.resolve()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:10.000 或 -3.500" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="reason" label="原因" rules={[{ required: true, message: '请输入原因' }]}>
|
||||||
|
<Input.TextArea rows={3} placeholder="必填,用于审计" />
|
||||||
|
</Form.Item>
|
||||||
|
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||||
|
单位换算:1 元 = 1000 厘。明细会写入后台流水用于审计。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={overviewOpen}
|
||||||
|
width={720}
|
||||||
|
title={overview ? `玩家详情 · ${overview.user.username}(#${overview.user.id})` : '玩家详情'}
|
||||||
|
onClose={() => {
|
||||||
|
setOverviewOpen(false)
|
||||||
|
setOverview(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overviewLoading ? (
|
||||||
|
<Typography.Paragraph>加载中...</Typography.Paragraph>
|
||||||
|
) : overview ? (
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Descriptions bordered size="small" column={2}>
|
||||||
|
<Descriptions.Item label="用户名">{overview.user.username}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="昵称">{overview.user.nickName || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={overview.user.enable === 1 ? 'green' : 'red'}>{overview.user.enable === 1 ? '启用' : '封禁'}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="在线">
|
||||||
|
<Tag color={overview.activity.isOnline ? 'green' : 'default'}>
|
||||||
|
{overview.activity.isOnline ? '在线' : '离线'}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账户余额(元)">{formatLi(overview.asset.accountBalanceLi)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="游戏币(元)">{formatLi(overview.asset.gameCoinBalanceLi)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最后登录">{overview.activity.lastLoginAt || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最后活跃">{overview.activity.lastActiveAt || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最后活跃IP">{overview.activity.lastActiveIp || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="邀请人">
|
||||||
|
{overview.invite.inviterUsername ? `${overview.invite.inviterUsername}(#${overview.invite.inviterUserId})` : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="已邀请人数">{overview.invite.invitedCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="未使用邀请码" span={2}>
|
||||||
|
{overview.invite.hasUnusedCode
|
||||||
|
? `尾号 ${overview.invite.unusedCodeLast4 || '-'},过期时间:${overview.invite.unusedExpiresAt || '-'}`
|
||||||
|
: '无'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Card size="small" title="最近登录日志(20条)">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: '时间', width: 170, dataIndex: 'createdAt' },
|
||||||
|
{ title: 'IP', width: 140, dataIndex: 'ip' },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 90,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Tag color={r.status ? 'green' : 'red'}>{r.status ? '成功' : '失败'}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '原因', dataIndex: 'errorMessage' },
|
||||||
|
]}
|
||||||
|
dataSource={overview.recentLoginLogs}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title="最近资产流水(20条)">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: '时间', width: 170, dataIndex: 'createdAt' },
|
||||||
|
{ title: '资产', width: 100, dataIndex: 'assetType' },
|
||||||
|
{
|
||||||
|
title: '变动(元)',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => formatLi(r.deltaLi),
|
||||||
|
},
|
||||||
|
{ title: '原因', dataIndex: 'reason' },
|
||||||
|
]}
|
||||||
|
dataSource={overview.recentTx}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Paragraph>暂无数据</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Alert, Button, Card, Col, Form, InputNumber, Radio, Row, Space, Switch, Typography, message } from 'antd'
|
||||||
|
import { sysParamsApi } from '@/lib/api'
|
||||||
|
|
||||||
|
const policyParamKeys = {
|
||||||
|
transferEnabled: 'asset.transfer_enabled',
|
||||||
|
transferMode: 'asset.transfer_mode', // warn_only|guardrail
|
||||||
|
transferFeeBps: 'asset.transfer_fee_bps', // 0-10000
|
||||||
|
transferDailyLimitLi: 'asset.transfer_daily_limit_li', // 0 means unlimited
|
||||||
|
transferDirection: 'asset.transfer_direction', // two_way|balance_to_coin|coin_to_balance
|
||||||
|
withdrawEnabled: 'asset.withdraw_enabled',
|
||||||
|
withdrawFeeBps: 'asset.withdraw_fee_bps',
|
||||||
|
withdrawDailyLimitLi: 'asset.withdraw_daily_limit_li',
|
||||||
|
rewardDestination: 'asset.reward_destination', // account|game_coin
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type Values = {
|
||||||
|
transferEnabled: boolean
|
||||||
|
transferMode: 'warn_only' | 'guardrail'
|
||||||
|
transferFeeBps: number
|
||||||
|
transferDailyLimitLi: number
|
||||||
|
transferDirection: 'two_way' | 'balance_to_coin' | 'coin_to_balance'
|
||||||
|
withdrawEnabled: boolean
|
||||||
|
withdrawFeeBps: number
|
||||||
|
withdrawDailyLimitLi: number
|
||||||
|
rewardDestination: 'account' | 'game_coin'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBool(v?: string) {
|
||||||
|
return String(v ?? '') === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInt(v?: string, fallback = 0) {
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetPolicyCenterPage() {
|
||||||
|
const [form] = Form.useForm<Values>()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [records, setRecords] = useState<Record<string, { ID: number; key: string } | undefined>>({})
|
||||||
|
|
||||||
|
const warnings = useMemo(() => {
|
||||||
|
const v = form.getFieldsValue()
|
||||||
|
const list: string[] = []
|
||||||
|
if (v.transferEnabled && v.transferMode === 'warn_only') {
|
||||||
|
list.push('互转已开启且为“仅提示不拦截”:活动赠送游戏币可能通过互转进入可提现资金池。')
|
||||||
|
}
|
||||||
|
if (v.withdrawEnabled && v.withdrawDailyLimitLi === 0) {
|
||||||
|
list.push('提现已开启且未设置日限额(0=不限制):建议至少配置日限额以降低风险。')
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}, [form])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const keys = Object.values(policyParamKeys)
|
||||||
|
const res = await Promise.all(keys.map((k) => sysParamsApi.getParamsList({ page: 1, pageSize: 5, key: k })))
|
||||||
|
const nextRecords: Record<string, { ID: number; key: string } | undefined> = {}
|
||||||
|
const values: Partial<Values> = {}
|
||||||
|
|
||||||
|
keys.forEach((k, i) => {
|
||||||
|
const list = res[i].data.list as Array<{ ID: number; key: string; value: string }>
|
||||||
|
const exact = list.find((it) => it.key === k) || list[0]
|
||||||
|
if (exact) nextRecords[k] = { ID: exact.ID, key: exact.key }
|
||||||
|
|
||||||
|
switch (k) {
|
||||||
|
case policyParamKeys.transferEnabled:
|
||||||
|
values.transferEnabled = toBool(exact?.value)
|
||||||
|
break
|
||||||
|
case policyParamKeys.transferMode:
|
||||||
|
values.transferMode = (exact?.value as Values['transferMode']) || 'warn_only'
|
||||||
|
break
|
||||||
|
case policyParamKeys.transferFeeBps:
|
||||||
|
values.transferFeeBps = toInt(exact?.value, 0)
|
||||||
|
break
|
||||||
|
case policyParamKeys.transferDailyLimitLi:
|
||||||
|
values.transferDailyLimitLi = toInt(exact?.value, 0)
|
||||||
|
break
|
||||||
|
case policyParamKeys.transferDirection:
|
||||||
|
values.transferDirection = (exact?.value as Values['transferDirection']) || 'two_way'
|
||||||
|
break
|
||||||
|
case policyParamKeys.withdrawEnabled:
|
||||||
|
values.withdrawEnabled = toBool(exact?.value)
|
||||||
|
break
|
||||||
|
case policyParamKeys.withdrawFeeBps:
|
||||||
|
values.withdrawFeeBps = toInt(exact?.value, 0)
|
||||||
|
break
|
||||||
|
case policyParamKeys.withdrawDailyLimitLi:
|
||||||
|
values.withdrawDailyLimitLi = toInt(exact?.value, 0)
|
||||||
|
break
|
||||||
|
case policyParamKeys.rewardDestination:
|
||||||
|
values.rewardDestination = (exact?.value as Values['rewardDestination']) || 'account'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setRecords(nextRecords)
|
||||||
|
form.setFieldsValue({
|
||||||
|
transferEnabled: values.transferEnabled ?? false,
|
||||||
|
transferMode: values.transferMode ?? 'warn_only',
|
||||||
|
transferFeeBps: values.transferFeeBps ?? 0,
|
||||||
|
transferDailyLimitLi: values.transferDailyLimitLi ?? 0,
|
||||||
|
transferDirection: values.transferDirection ?? 'two_way',
|
||||||
|
withdrawEnabled: values.withdrawEnabled ?? false,
|
||||||
|
withdrawFeeBps: values.withdrawFeeBps ?? 0,
|
||||||
|
withdrawDailyLimitLi: values.withdrawDailyLimitLi ?? 0,
|
||||||
|
rewardDestination: values.rewardDestination ?? 'account',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [form])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const upsert = async (key: string, payload: { name: string; desc: string; value: string }) => {
|
||||||
|
const existing = records[key]
|
||||||
|
if (existing?.ID) {
|
||||||
|
await sysParamsApi.updateParam({ ID: existing.ID, key, ...payload })
|
||||||
|
} else {
|
||||||
|
await sysParamsApi.createParam({ key, ...payload })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const v = await form.validateFields()
|
||||||
|
|
||||||
|
const tasks: Array<Promise<any>> = []
|
||||||
|
tasks.push(
|
||||||
|
upsert(policyParamKeys.transferEnabled, { name: '互转开关', desc: '0/1(1=开启互转)', value: v.transferEnabled ? '1' : '0' })
|
||||||
|
)
|
||||||
|
tasks.push(
|
||||||
|
upsert(policyParamKeys.transferMode, {
|
||||||
|
name: '互转风险模式',
|
||||||
|
desc: 'warn_only|guardrail(仅提示/护栏模式)',
|
||||||
|
value: v.transferMode,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
tasks.push(
|
||||||
|
upsert(policyParamKeys.transferFeeBps, {
|
||||||
|
name: '互转手续费(万分比)',
|
||||||
|
desc: '0-10000(例如 50=0.5%)',
|
||||||
|
value: String(v.transferFeeBps ?? 0),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
tasks.push(
|
||||||
|
upsert(policyParamKeys.transferDailyLimitLi, {
|
||||||
|
name: '互转日限额(厘)',
|
||||||
|
desc: '0 表示不限制(单位:厘)',
|
||||||
|
value: String(v.transferDailyLimitLi ?? 0),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
tasks.push(
|
||||||
|
upsert(policyParamKeys.transferDirection, {
|
||||||
|
name: '互转方向',
|
||||||
|
desc: 'two_way|balance_to_coin|coin_to_balance',
|
||||||
|
value: v.transferDirection,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.push(upsert(policyParamKeys.withdrawEnabled, { name: '提现开关', desc: '0/1(1=开启提现)', value: v.withdrawEnabled ? '1' : '0' }))
|
||||||
|
tasks.push(
|
||||||
|
upsert(policyParamKeys.withdrawFeeBps, {
|
||||||
|
name: '提现手续费(万分比)',
|
||||||
|
desc: '0-10000(例如 100=1%)',
|
||||||
|
value: String(v.withdrawFeeBps ?? 0),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
tasks.push(
|
||||||
|
upsert(policyParamKeys.withdrawDailyLimitLi, {
|
||||||
|
name: '提现日限额(厘)',
|
||||||
|
desc: '0 表示不限制(单位:厘)',
|
||||||
|
value: String(v.withdrawDailyLimitLi ?? 0),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
upsert(policyParamKeys.rewardDestination, { name: '奖励入账去向', desc: 'account|game_coin', value: v.rewardDestination })
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.all(tasks)
|
||||||
|
message.success('资产策略已保存(已自动写入变更审计)')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card className="glass-panel page-panel" loading={loading}>
|
||||||
|
<div className="section-heading" style={{ marginBottom: 8 }}>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={3} style={{ marginBottom: 0 }}>
|
||||||
|
资产策略中心
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||||
|
互转/提现/奖励去向等关键策略集中在此配置;所有修改会写入“参数变更审计”。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={load} loading={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={save} loading={loading}>
|
||||||
|
保存策略
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
{warnings.length ? (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="风险提示"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
{warnings.map((w) => (
|
||||||
|
<div key={w}>{w}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
transferEnabled: false,
|
||||||
|
transferMode: 'warn_only',
|
||||||
|
transferFeeBps: 0,
|
||||||
|
transferDailyLimitLi: 0,
|
||||||
|
transferDirection: 'two_way',
|
||||||
|
withdrawEnabled: false,
|
||||||
|
withdrawFeeBps: 0,
|
||||||
|
withdrawDailyLimitLi: 0,
|
||||||
|
rewardDestination: 'account',
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
onValuesChange={() => {
|
||||||
|
// 触发 warnings 刷新
|
||||||
|
setTimeout(() => {}, 0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card size="small" title="互转策略">
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="transferEnabled" label="互转开关" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="transferMode" label="风险模式" rules={[{ required: true }]}>
|
||||||
|
<Radio.Group
|
||||||
|
options={[
|
||||||
|
{ label: '仅提示不拦截', value: 'warn_only' },
|
||||||
|
{ label: '护栏模式(预留)', value: 'guardrail' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="transferFeeBps" label="手续费(万分比)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} max={10000} step={10} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="transferDailyLimitLi" label="日限额(厘,0=不限制)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} step={1000} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24}>
|
||||||
|
<Form.Item name="transferDirection" label="互转方向" rules={[{ required: true }]}>
|
||||||
|
<Radio.Group
|
||||||
|
options={[
|
||||||
|
{ label: '双向互转', value: 'two_way' },
|
||||||
|
{ label: '仅 余额 → 游戏币', value: 'balance_to_coin' },
|
||||||
|
{ label: '仅 游戏币 → 余额', value: 'coin_to_balance' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card size="small" title="提现与奖励策略">
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="withdrawEnabled" label="提现开关" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="rewardDestination" label="奖励入账去向" rules={[{ required: true }]}>
|
||||||
|
<Radio.Group
|
||||||
|
options={[
|
||||||
|
{ label: '账户余额', value: 'account' },
|
||||||
|
{ label: '游戏币', value: 'game_coin' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="withdrawFeeBps" label="提现手续费(万分比)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} max={10000} step={10} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="withdrawDailyLimitLi" label="提现日限额(厘,0=不限制)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} step={1000} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -19,6 +19,51 @@ export const moduleCatalog: Record<string, ModuleDescriptor> = {
|
|||||||
features: ['项目说明', '技术栈梳理', '外部链接入口'],
|
features: ['项目说明', '技术栈梳理', '外部链接入口'],
|
||||||
endpoints: [],
|
endpoints: [],
|
||||||
},
|
},
|
||||||
|
userCenter: {
|
||||||
|
name: 'userCenter',
|
||||||
|
title: '用户中心',
|
||||||
|
group: '业务中心',
|
||||||
|
status: 'partial',
|
||||||
|
summary: '聚合玩家/用户相关的运营能力与策略配置入口。',
|
||||||
|
features: ['玩家管理', '注册策略', '邀请关系', '封禁与风控(规划)'],
|
||||||
|
endpoints: ['/admin/app-users', '/public/app/register/status'],
|
||||||
|
},
|
||||||
|
financeCenter: {
|
||||||
|
name: 'financeCenter',
|
||||||
|
title: '财务中心',
|
||||||
|
group: '业务中心',
|
||||||
|
status: 'partial',
|
||||||
|
summary: '聚合充值、积分、账单、对账与风控策略等财务能力入口。',
|
||||||
|
features: ['订单/充值(规划)', '积分与余额(规划)', '账单流水(规划)'],
|
||||||
|
endpoints: [],
|
||||||
|
},
|
||||||
|
withdrawManage: {
|
||||||
|
name: 'withdrawManage',
|
||||||
|
title: '提现管理',
|
||||||
|
group: '业务中心',
|
||||||
|
status: 'partial',
|
||||||
|
summary: '管理提现订单的审核、打款与失败回滚,提供财务流程的统一入口。',
|
||||||
|
features: ['提现订单列表', '订单详情', '审核通过/拒绝', '打款确认/失败回滚'],
|
||||||
|
endpoints: ['/admin/app-withdraw-orders', '/admin/app-withdraw-orders/:id'],
|
||||||
|
},
|
||||||
|
gameCenter: {
|
||||||
|
name: 'gameCenter',
|
||||||
|
title: '游戏中心',
|
||||||
|
group: '业务中心',
|
||||||
|
status: 'partial',
|
||||||
|
summary: '聚合游戏内容、配置、审核与数据看板等能力入口。',
|
||||||
|
features: ['内容管理(规划)', '游戏配置(规划)', '数据看板(规划)'],
|
||||||
|
endpoints: [],
|
||||||
|
},
|
||||||
|
opsConfig: {
|
||||||
|
name: 'opsConfig',
|
||||||
|
title: '运营配置',
|
||||||
|
group: '平台治理',
|
||||||
|
status: 'partial',
|
||||||
|
summary: '聚合功能开关、策略与运营配置入口,用于承载高频变更项。',
|
||||||
|
features: ['注册策略', '验证码/风控策略(规划)', '功能开关(规划)'],
|
||||||
|
endpoints: ['/sysParams/getSysParamsList'],
|
||||||
|
},
|
||||||
superAdmin: {
|
superAdmin: {
|
||||||
name: 'superAdmin',
|
name: 'superAdmin',
|
||||||
title: '超级管理员',
|
title: '超级管理员',
|
||||||
|
|||||||
363
web-admin/src/features/inviteCodes/AdminInviteCodesPage.tsx
Normal file
363
web-admin/src/features/inviteCodes/AdminInviteCodesPage.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Button, Card, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { appInviteCodesAdminApi } from '@/lib/api'
|
||||||
|
import type { AppInviteCodeDetail, AppInviteCodeRecord, AppInviteCodeStatus } from '@/types/system'
|
||||||
|
|
||||||
|
type SearchValues = {
|
||||||
|
status?: AppInviteCodeStatus
|
||||||
|
createdByUserId?: string
|
||||||
|
usedByUserId?: string
|
||||||
|
codeLast4?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type IssueValues = {
|
||||||
|
userId: string
|
||||||
|
expireHours?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClearValues = {
|
||||||
|
userId: string
|
||||||
|
scope: 'active' | 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel: Record<AppInviteCodeStatus, string> = {
|
||||||
|
unused: '未使用',
|
||||||
|
used: '已使用',
|
||||||
|
revoked: '已作废',
|
||||||
|
expired: '已过期',
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: string | undefined) {
|
||||||
|
if (!value) return undefined
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminInviteCodesPage() {
|
||||||
|
const [form] = Form.useForm<SearchValues>()
|
||||||
|
const [issueForm] = Form.useForm<IssueValues>()
|
||||||
|
const [clearForm] = Form.useForm<ClearValues>()
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [items, setItems] = useState<AppInviteCodeRecord[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
const [detail, setDetail] = useState<AppInviteCodeDetail | null>(null)
|
||||||
|
|
||||||
|
const [issueOpen, setIssueOpen] = useState(false)
|
||||||
|
const [issuing, setIssuing] = useState(false)
|
||||||
|
const [issuedCode, setIssuedCode] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [clearOpen, setClearOpen] = useState(false)
|
||||||
|
const [clearing, setClearing] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
const res = await appInviteCodesAdminApi.getList({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
status: values.status,
|
||||||
|
createdByUserId: asNumber(values.createdByUserId),
|
||||||
|
usedByUserId: asNumber(values.usedByUserId),
|
||||||
|
codeLast4: values.codeLast4?.trim() || undefined,
|
||||||
|
})
|
||||||
|
setItems(res.data.list)
|
||||||
|
setTotal(res.data.total)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [form, page, pageSize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const openDetail = async (record: AppInviteCodeRecord) => {
|
||||||
|
setDetailOpen(true)
|
||||||
|
setDetailLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await appInviteCodesAdminApi.getById(record.id)
|
||||||
|
setDetail(res.data)
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<AppInviteCodeRecord> = useMemo(
|
||||||
|
() => [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||||
|
{ title: '邀请人ID', dataIndex: 'createdByUserId', width: 120 },
|
||||||
|
{ title: '末4位', dataIndex: 'codeLast4', width: 110 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 110,
|
||||||
|
render: (value: AppInviteCodeStatus) => (
|
||||||
|
<Tag color={value === 'used' ? 'green' : value === 'unused' ? 'blue' : value === 'expired' ? 'orange' : 'red'}>
|
||||||
|
{statusLabel[value]}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '过期时间', dataIndex: 'expiresAt', width: 190 },
|
||||||
|
{ title: '使用人ID', dataIndex: 'usedByUserId', width: 120 },
|
||||||
|
{ title: '使用时间', dataIndex: 'usedAt', width: 190 },
|
||||||
|
{ title: '创建时间', dataIndex: 'createdAt', width: 190 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
fixed: 'right',
|
||||||
|
width: 160,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size={8}>
|
||||||
|
<Button type="link" onClick={() => openDetail(record)} style={{ padding: 0 }}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ padding: 0, color: '#cf1322' }}
|
||||||
|
disabled={record.status !== 'unused'}
|
||||||
|
onClick={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认作废该邀请码?',
|
||||||
|
content: `ID=${record.id},末4位=${record.codeLast4}`,
|
||||||
|
okText: '作废',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
await appInviteCodesAdminApi.revoke(record.id)
|
||||||
|
message.success('已作废')
|
||||||
|
load()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
作废
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[load],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
onFinish={() => {
|
||||||
|
setPage(1)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<Space wrap>
|
||||||
|
<Form.Item name="status">
|
||||||
|
<Select
|
||||||
|
placeholder="状态"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ label: '未使用', value: 'unused' },
|
||||||
|
{ label: '已使用', value: 'used' },
|
||||||
|
{ label: '已作废', value: 'revoked' },
|
||||||
|
{ label: '已过期', value: 'expired' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="codeLast4">
|
||||||
|
<Input placeholder="末4位" allowClear style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="createdByUserId">
|
||||||
|
<Input placeholder="邀请人ID" allowClear style={{ width: 130 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="usedByUserId">
|
||||||
|
<Input placeholder="使用人ID" allowClear style={{ width: 130 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields()
|
||||||
|
setPage(1)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
issueForm.resetFields()
|
||||||
|
setIssuedCode(null)
|
||||||
|
setIssueOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
代生成
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
clearForm.resetFields()
|
||||||
|
clearForm.setFieldsValue({ scope: 'active' })
|
||||||
|
setClearOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空未使用码
|
||||||
|
</Button>
|
||||||
|
<Button onClick={load}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={items}
|
||||||
|
scroll={{ x: 1250 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={detailOpen}
|
||||||
|
title="邀请码详情"
|
||||||
|
onClose={() => {
|
||||||
|
setDetailOpen(false)
|
||||||
|
setDetail(null)
|
||||||
|
}}
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
{detailLoading ? (
|
||||||
|
<Typography.Text className="text-muted">加载中...</Typography.Text>
|
||||||
|
) : detail ? (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card size="small" className="glass-panel">
|
||||||
|
<Space direction="vertical" size={6}>
|
||||||
|
<div>邀请码ID:{detail.code.id}</div>
|
||||||
|
<div>末4位:{detail.code.codeLast4}</div>
|
||||||
|
<div>状态:{statusLabel[detail.code.status]}</div>
|
||||||
|
<div>过期时间:{detail.code.expiresAt || '-'}</div>
|
||||||
|
<div>创建时间:{detail.code.createdAt}</div>
|
||||||
|
<div>使用时间:{detail.code.usedAt || '-'}</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
<Card size="small" className="glass-panel" title="关联用户">
|
||||||
|
<Space direction="vertical" size={6}>
|
||||||
|
<div>
|
||||||
|
邀请人:{detail.inviter.username}(ID={detail.inviter.id})
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
被邀请人:{detail.invitee ? `${detail.invitee.username}(ID=${detail.invitee.id})` : '-'}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Typography.Text className="text-muted">暂无数据</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={issueOpen}
|
||||||
|
title="后台代生成邀请码"
|
||||||
|
onCancel={() => setIssueOpen(false)}
|
||||||
|
confirmLoading={issuing}
|
||||||
|
onOk={async () => {
|
||||||
|
const values = await issueForm.validateFields()
|
||||||
|
const userId = Number(values.userId)
|
||||||
|
const expireHours = values.expireHours ? Number(values.expireHours) : undefined
|
||||||
|
if (!Number.isFinite(userId) || userId <= 0) {
|
||||||
|
message.error('用户ID不合法')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIssuing(true)
|
||||||
|
try {
|
||||||
|
const res = await appInviteCodesAdminApi.issue({
|
||||||
|
userId,
|
||||||
|
expireHours: Number.isFinite(expireHours) && (expireHours as number) > 0 ? (expireHours as number) : undefined,
|
||||||
|
})
|
||||||
|
setIssuedCode(res.data.code)
|
||||||
|
} finally {
|
||||||
|
setIssuing(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={issueForm} layout="vertical">
|
||||||
|
<Form.Item name="userId" label="用户ID" rules={[{ required: true, message: '请输入用户ID' }]}>
|
||||||
|
<Input inputMode="numeric" placeholder="例如:123" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="expireHours" label="过期小时数(可选)">
|
||||||
|
<Input inputMode="numeric" placeholder="默认使用系统配置" />
|
||||||
|
</Form.Item>
|
||||||
|
{issuedCode ? (
|
||||||
|
<Card size="small">
|
||||||
|
<Typography.Text strong>邀请码(仅展示一次,请及时复制):</Typography.Text>
|
||||||
|
<div style={{ marginTop: 8, wordBreak: 'break-all' }}>{issuedCode}</div>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={clearOpen}
|
||||||
|
title="强制清空未使用码"
|
||||||
|
onCancel={() => setClearOpen(false)}
|
||||||
|
confirmLoading={clearing}
|
||||||
|
onOk={async () => {
|
||||||
|
const values = await clearForm.validateFields()
|
||||||
|
const userId = Number(values.userId)
|
||||||
|
if (!Number.isFinite(userId) || userId <= 0) {
|
||||||
|
message.error('用户ID不合法')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setClearing(true)
|
||||||
|
try {
|
||||||
|
await appInviteCodesAdminApi.clearUnused({ userId, scope: values.scope })
|
||||||
|
message.success('已清空')
|
||||||
|
setClearOpen(false)
|
||||||
|
load()
|
||||||
|
} finally {
|
||||||
|
setClearing(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={clearForm} layout="vertical" initialValues={{ scope: 'active' }}>
|
||||||
|
<Form.Item name="userId" label="用户ID" rules={[{ required: true, message: '请输入用户ID' }]}>
|
||||||
|
<Input inputMode="numeric" placeholder="例如:123" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="scope" label="范围" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ label: '仅未过期未使用(active)', value: 'active' },
|
||||||
|
{ label: '全部未使用(all)', value: 'all' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Avatar, Button, Dropdown, Menu, Select, Space, Typography, message } from 'antd'
|
import { Avatar, Button, Dropdown, Menu, Select, Space, Typography, message } from 'antd'
|
||||||
import type { ItemType } from 'antd/es/menu/interface'
|
import type { ItemType } from 'antd/es/menu/interface'
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
PartitionOutlined,
|
PartitionOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@@ -68,6 +70,8 @@ type Props = {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sidebarCollapsedStorageKey = 'admin_sidebar_collapsed'
|
||||||
|
|
||||||
export function AdminShell({ children }: Props) {
|
export function AdminShell({ children }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -77,6 +81,12 @@ export function AdminShell({ children }: Props) {
|
|||||||
const setUser = useAuthStore((state) => state.setUser)
|
const setUser = useAuthStore((state) => state.setUser)
|
||||||
const setMenus = useAuthStore((state) => state.setMenus)
|
const setMenus = useAuthStore((state) => state.setMenus)
|
||||||
const selectedKey = location.pathname.replace(/^\//, '')
|
const selectedKey = location.pathname.replace(/^\//, '')
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(sidebarCollapsedStorageKey)
|
||||||
|
setCollapsed(stored === '1')
|
||||||
|
}, [])
|
||||||
|
|
||||||
const breadcrumb = useMemo(() => {
|
const breadcrumb = useMemo(() => {
|
||||||
const target = flattenMenus(menus).find((menu) => menu.fullPath === location.pathname)
|
const target = flattenMenus(menus).find((menu) => menu.fullPath === location.pathname)
|
||||||
@@ -146,26 +156,45 @@ export function AdminShell({ children }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = !prev
|
||||||
|
localStorage.setItem(sidebarCollapsedStorageKey, next ? '1' : '0')
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-shell">
|
<div className={collapsed ? 'admin-shell is-collapsed' : 'admin-shell'}>
|
||||||
<aside className="admin-sidebar">
|
<aside className="admin-sidebar">
|
||||||
<div className="admin-brand">
|
<div className="admin-brand">
|
||||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.65)' }}>React Admin</Typography.Text>
|
<div className="admin-brand-header">
|
||||||
<Typography.Title level={3} style={{ color: 'rgba(255,255,255,0.96)', margin: '8px 0 6px' }}>
|
<div>
|
||||||
RMK Control Deck
|
<Typography.Text style={{ color: 'rgba(255,255,255,0.65)' }}>React Admin</Typography.Text>
|
||||||
</Typography.Title>
|
<Typography.Title level={3} style={{ color: 'rgba(255,255,255,0.96)', margin: '8px 0 6px' }}>
|
||||||
<Typography.Paragraph style={{ color: 'rgba(255,255,255,0.72)', marginBottom: 0 }}>
|
RMK Control Deck
|
||||||
基于现有 Gin 后端协议构建的新管理台。
|
</Typography.Title>
|
||||||
</Typography.Paragraph>
|
<Typography.Paragraph style={{ color: 'rgba(255,255,255,0.72)', marginBottom: 0 }}>
|
||||||
|
基于现有 Gin 后端协议构建的新管理台。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
aria-label={collapsed ? '展开菜单' : '收缩菜单'}
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
style={{ color: 'rgba(255,255,255,0.86)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Menu
|
<Menu
|
||||||
className="admin-nav-menu"
|
className="admin-nav-menu"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
theme="dark"
|
theme="dark"
|
||||||
inlineIndent={16}
|
inlineIndent={16}
|
||||||
|
inlineCollapsed={collapsed}
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={[selectedKey]}
|
||||||
defaultOpenKeys={defaultOpenKeys}
|
defaultOpenKeys={defaultOpenKeys}
|
||||||
motion={false}
|
|
||||||
items={menuItems}
|
items={menuItems}
|
||||||
onClick={handleMenuClick}
|
onClick={handleMenuClick}
|
||||||
style={{ width: '100%', background: 'transparent', borderInlineEnd: 'none' }}
|
style={{ width: '100%', background: 'transparent', borderInlineEnd: 'none' }}
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ function resolveMenuComponentDisplay(menu: Pick<MenuNode, 'name' | 'component'>)
|
|||||||
return getComponentLabelByRouteName(menu.name) || resolveMenuComponentValue(menu)
|
return getComponentLabelByRouteName(menu.name) || resolveMenuComponentValue(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDepthMap(nodes: MenuNode[]) {
|
||||||
|
const map = new WeakMap<MenuNode, number>()
|
||||||
|
const walk = (items: MenuNode[], depth: number) => {
|
||||||
|
for (const item of items) {
|
||||||
|
map.set(item, depth)
|
||||||
|
if (item.children?.length) {
|
||||||
|
walk(item.children, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(nodes, 0)
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
export function MenuManagementPage() {
|
export function MenuManagementPage() {
|
||||||
const [form] = Form.useForm<MenuFormValues>()
|
const [form] = Form.useForm<MenuFormValues>()
|
||||||
const [menus, setMenus] = useState<MenuNode[]>([])
|
const [menus, setMenus] = useState<MenuNode[]>([])
|
||||||
@@ -159,6 +173,8 @@ export function MenuManagementPage() {
|
|||||||
[menus],
|
[menus],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const depthMap = useMemo(() => buildDepthMap(menus), [menus])
|
||||||
|
|
||||||
const reloadMenus = async () => {
|
const reloadMenus = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -286,11 +302,19 @@ export function MenuManagementPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<MenuNode> = [
|
const columns: ColumnsType<MenuNode> = [
|
||||||
{ title: 'ID', dataIndex: 'ID', width: 80 },
|
{ title: 'ID', dataIndex: 'ID', width: 96 },
|
||||||
{
|
{
|
||||||
title: '展示名称',
|
title: '展示名称',
|
||||||
width: 180,
|
width: 240,
|
||||||
render: (_, record) => record.meta.title,
|
render: (_, record) => {
|
||||||
|
const depth = depthMap.get(record) ?? 0
|
||||||
|
const hasChildren = Boolean(record.children?.length)
|
||||||
|
return (
|
||||||
|
<div style={{ paddingLeft: depth * 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontWeight: hasChildren ? 600 : 400 }}>{record.meta.title}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '图标',
|
title: '图标',
|
||||||
@@ -366,9 +390,9 @@ export function MenuManagementPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={menus}
|
dataSource={menus}
|
||||||
expandable={{ defaultExpandAllRows: true }}
|
expandable={{ defaultExpandAllRows: true, indentSize: 18 }}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 1700 }}
|
scroll={{ x: 1760 }}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -13,10 +13,46 @@ type ComponentTreeNode = {
|
|||||||
const componentOptions: ComponentOption[] = [
|
const componentOptions: ComponentOption[] = [
|
||||||
{ value: 'features/dashboard/DashboardPage', label: 'DashboardPage · 仪表盘', routeName: 'dashboard' },
|
{ value: 'features/dashboard/DashboardPage', label: 'DashboardPage · 仪表盘', routeName: 'dashboard' },
|
||||||
{ value: 'features/discovery/ModuleLandingPage:about', label: 'ModuleLandingPage · 关于系统', routeName: 'about' },
|
{ value: 'features/discovery/ModuleLandingPage:about', label: 'ModuleLandingPage · 关于系统', routeName: 'about' },
|
||||||
|
{ value: 'features/discovery/ModuleLandingPage:userCenter', label: 'ModuleLandingPage · 用户中心', routeName: 'userCenter' },
|
||||||
|
{ value: 'features/discovery/ModuleLandingPage:financeCenter', label: 'ModuleLandingPage · 财务中心', routeName: 'financeCenter' },
|
||||||
|
{ value: 'features/discovery/ModuleLandingPage:withdrawManage', label: 'ModuleLandingPage · 提现管理(分组)', routeName: 'withdrawManage' },
|
||||||
|
{ value: 'features/discovery/ModuleLandingPage:gameCenter', label: 'ModuleLandingPage · 游戏中心', routeName: 'gameCenter' },
|
||||||
|
{ value: 'features/discovery/ModuleLandingPage:opsConfig', label: 'ModuleLandingPage · 运营配置', routeName: 'opsConfig' },
|
||||||
{ value: 'features/roles/RoleManagementPage', label: 'RoleManagementPage · 角色管理', routeName: 'authority' },
|
{ value: 'features/roles/RoleManagementPage', label: 'RoleManagementPage · 角色管理', routeName: 'authority' },
|
||||||
{ value: 'features/menus/MenuManagementPage', label: 'MenuManagementPage · 菜单管理', routeName: 'menu' },
|
{ value: 'features/menus/MenuManagementPage', label: 'MenuManagementPage · 菜单管理', routeName: 'menu' },
|
||||||
{ value: 'features/apis/ApiManagementPage', label: 'ApiManagementPage · API 管理', routeName: 'api' },
|
{ value: 'features/apis/ApiManagementPage', label: 'ApiManagementPage · API 管理', routeName: 'api' },
|
||||||
{ value: 'features/users/UserManagementPage', label: 'UserManagementPage · 用户管理', routeName: 'user' },
|
{ value: 'features/users/UserManagementPage', label: 'UserManagementPage · 用户管理', routeName: 'user' },
|
||||||
|
{ value: 'features/appPlayers/AppPlayersPage', label: 'AppPlayersPage · 玩家管理', routeName: 'appPlayers' },
|
||||||
|
{
|
||||||
|
value: 'features/appAssetTransactions/AppAssetTransactionsPage',
|
||||||
|
label: 'AppAssetTransactionsPage · 玩家资产流水',
|
||||||
|
routeName: 'appAssetTransactions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'features/withdrawOrders/WithdrawOrdersPage',
|
||||||
|
label: 'WithdrawOrdersPage · 提现订单',
|
||||||
|
routeName: 'withdrawOrders',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'features/assetPolicyCenter/AssetPolicyCenterPage',
|
||||||
|
label: 'AssetPolicyCenterPage · 资产策略中心',
|
||||||
|
routeName: 'assetPolicyCenter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'features/riskTags/RiskTagsPage',
|
||||||
|
label: 'RiskTagsPage · 风险标签',
|
||||||
|
routeName: 'riskTags',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'features/registerPolicy/RegisterPolicyPage',
|
||||||
|
label: 'RegisterPolicyPage · 玩家注册策略',
|
||||||
|
routeName: 'registerPolicy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'features/inviteCodes/AdminInviteCodesPage',
|
||||||
|
label: 'AdminInviteCodesPage · 邀请码管理',
|
||||||
|
routeName: 'inviteCodes',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'features/appManage/AppManageLandingPage',
|
value: 'features/appManage/AppManageLandingPage',
|
||||||
label: 'AppManageLandingPage · 应用管理总览',
|
label: 'AppManageLandingPage · 应用管理总览',
|
||||||
@@ -149,9 +185,15 @@ const componentOptions: ComponentOption[] = [
|
|||||||
},
|
},
|
||||||
{ value: 'features/logs/OperationLogPage', label: 'OperationLogPage · 操作历史', routeName: 'operation' },
|
{ value: 'features/logs/OperationLogPage', label: 'OperationLogPage · 操作历史', routeName: 'operation' },
|
||||||
{ value: 'features/params/ParamsManagementPage', label: 'ParamsManagementPage · 参数管理', routeName: 'sysParams' },
|
{ value: 'features/params/ParamsManagementPage', label: 'ParamsManagementPage · 参数管理', routeName: 'sysParams' },
|
||||||
|
{
|
||||||
|
value: 'features/params/SysParamChangeLogPage',
|
||||||
|
label: 'SysParamChangeLogPage · 参数变更审计',
|
||||||
|
routeName: 'sysParamChangeLog',
|
||||||
|
},
|
||||||
{ value: 'features/system/SystemConfigPage', label: 'SystemConfigPage · 系统配置', routeName: 'system' },
|
{ value: 'features/system/SystemConfigPage', label: 'SystemConfigPage · 系统配置', routeName: 'system' },
|
||||||
{ value: 'features/tokens/ApiTokenPage', label: 'ApiTokenPage · API Token', routeName: 'apiToken' },
|
{ value: 'features/tokens/ApiTokenPage', label: 'ApiTokenPage · API Token', routeName: 'apiToken' },
|
||||||
{ value: 'features/logs/LoginLogPage', label: 'LoginLogPage · 登录日志', routeName: 'loginLog' },
|
{ value: 'features/logs/LoginLogPage', label: 'LoginLogPage · 登录日志', routeName: 'loginLog' },
|
||||||
|
{ value: 'features/appLoginLogs/AppLoginLogsPage', label: 'AppLoginLogsPage · 玩家登录日志', routeName: 'appLoginLog' },
|
||||||
{ value: 'features/errors/ErrorLogPage', label: 'ErrorLogPage · 错误日志', routeName: 'sysError' },
|
{ value: 'features/errors/ErrorLogPage', label: 'ErrorLogPage · 错误日志', routeName: 'sysError' },
|
||||||
{ value: 'features/person/ProfilePage', label: 'ProfilePage · 个人中心', routeName: 'person' },
|
{ value: 'features/person/ProfilePage', label: 'ProfilePage · 个人中心', routeName: 'person' },
|
||||||
{ value: 'features/server/ServerStatePage', label: 'ServerStatePage · 服务器状态', routeName: 'state' },
|
{ value: 'features/server/ServerStatePage', label: 'ServerStatePage · 服务器状态', routeName: 'state' },
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export function ParamsManagementPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="glass-panel page-panel">
|
<Card className="glass-panel page-panel">
|
||||||
<Table
|
<Table
|
||||||
rowKey="ID"
|
rowKey="ID"
|
||||||
|
|||||||
154
web-admin/src/features/params/SysParamChangeLogPage.tsx
Normal file
154
web-admin/src/features/params/SysParamChangeLogPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Button, Card, Form, Input, Space, Table, Tag, Typography } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { sysParamsApi } from '@/lib/api'
|
||||||
|
import { formatDate } from '@/lib/date'
|
||||||
|
|
||||||
|
type ChangeLog = {
|
||||||
|
id: number
|
||||||
|
paramId: number
|
||||||
|
key: string
|
||||||
|
action: 'create' | 'update' | 'delete' | string
|
||||||
|
oldValue?: string
|
||||||
|
newValue?: string
|
||||||
|
operatorUserId: number
|
||||||
|
operatorName?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Search = {
|
||||||
|
key?: string
|
||||||
|
operatorUserId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SysParamChangeLogPage() {
|
||||||
|
const [form] = Form.useForm<Search>()
|
||||||
|
const [rows, setRows] = useState<ChangeLog[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
const res = await sysParamsApi.getChangeLogList({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
key: values.key?.trim() || undefined,
|
||||||
|
operatorUserId: values.operatorUserId?.trim() || undefined,
|
||||||
|
})
|
||||||
|
setRows(res.data.list as unknown as ChangeLog[])
|
||||||
|
setTotal(res.data.total)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [form, page, pageSize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const columns: ColumnsType<ChangeLog> = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||||
|
{ title: 'Key', dataIndex: 'key', width: 260 },
|
||||||
|
{
|
||||||
|
title: '动作',
|
||||||
|
dataIndex: 'action',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => {
|
||||||
|
const map: Record<string, { label: string; color: string }> = {
|
||||||
|
create: { label: '新建', color: 'green' },
|
||||||
|
update: { label: '更新', color: 'blue' },
|
||||||
|
delete: { label: '删除', color: 'red' },
|
||||||
|
}
|
||||||
|
const it = map[String(v)] ?? { label: String(v), color: 'default' }
|
||||||
|
return <Tag color={it.color}>{it.label}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '旧值', dataIndex: 'oldValue', ellipsis: true },
|
||||||
|
{ title: '新值', dataIndex: 'newValue', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '操作人',
|
||||||
|
width: 170,
|
||||||
|
render: (_, r) => (r.operatorName ? `${r.operatorName}(#${r.operatorUserId})` : `#${r.operatorUserId}`),
|
||||||
|
},
|
||||||
|
{ title: '时间', width: 190, render: (_, r) => formatDate(r.createdAt) },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||||
|
参数变更审计
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||||
|
用于追溯运营开关/策略配置的变更记录(谁改的、改了什么)。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
onFinish={() => {
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="key" label="Key">
|
||||||
|
<Input allowClear placeholder="模糊搜索 key" style={{ width: 320 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="operatorUserId" label="操作人ID">
|
||||||
|
<Input allowClear placeholder="例如 1" style={{ width: 180 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields()
|
||||||
|
setPage(1)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
153
web-admin/src/features/registerPolicy/RegisterPolicyPage.tsx
Normal file
153
web-admin/src/features/registerPolicy/RegisterPolicyPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Button, Card, Col, Form, Radio, Row, Select, Space, Switch, Typography, message } from 'antd'
|
||||||
|
import { sysParamsApi } from '@/lib/api'
|
||||||
|
|
||||||
|
const registerParamKeys = {
|
||||||
|
mode: 'auth.register_mode',
|
||||||
|
requireCaptcha: 'auth.register_require_captcha',
|
||||||
|
inviteExpireHours: 'auth.invite_code_expire_hours',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type RegisterPolicyValues = {
|
||||||
|
mode: 'closed' | 'open' | 'invite'
|
||||||
|
requireCaptcha: boolean
|
||||||
|
inviteExpireHours: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterPolicyPage() {
|
||||||
|
const [form] = Form.useForm<RegisterPolicyValues>()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [records, setRecords] = useState<Partial<Record<keyof typeof registerParamKeys, { ID: number; key: string }>>>(
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [modeRes, captchaRes, expireRes] = await Promise.all([
|
||||||
|
sysParamsApi.getParamsList({ page: 1, pageSize: 5, key: registerParamKeys.mode }),
|
||||||
|
sysParamsApi.getParamsList({ page: 1, pageSize: 5, key: registerParamKeys.requireCaptcha }),
|
||||||
|
sysParamsApi.getParamsList({ page: 1, pageSize: 5, key: registerParamKeys.inviteExpireHours }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const pickExact = (list: { ID: number; key: string; value: string }[], key: string) =>
|
||||||
|
list.find((item) => item.key === key) || list[0]
|
||||||
|
const modeRecord = pickExact(modeRes.data.list, registerParamKeys.mode)
|
||||||
|
const captchaRecord = pickExact(captchaRes.data.list, registerParamKeys.requireCaptcha)
|
||||||
|
const expireRecord = pickExact(expireRes.data.list, registerParamKeys.inviteExpireHours)
|
||||||
|
|
||||||
|
setRecords({
|
||||||
|
mode: modeRecord ? { ID: modeRecord.ID, key: modeRecord.key } : undefined,
|
||||||
|
requireCaptcha: captchaRecord ? { ID: captchaRecord.ID, key: captchaRecord.key } : undefined,
|
||||||
|
inviteExpireHours: expireRecord ? { ID: expireRecord.ID, key: expireRecord.key } : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
form.setFieldsValue({
|
||||||
|
mode: (modeRecord?.value as RegisterPolicyValues['mode']) || 'closed',
|
||||||
|
requireCaptcha: captchaRecord?.value === '1',
|
||||||
|
inviteExpireHours: Number(expireRecord?.value || 24) || 24,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [form])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
|
||||||
|
const upsert = async (key: string, payload: { name: string; desc: string; value: string }) => {
|
||||||
|
const existing = Object.values(records).find((item) => item?.key === key) ?? null
|
||||||
|
if (existing?.ID) {
|
||||||
|
await sysParamsApi.updateParam({ ID: existing.ID, key, ...payload })
|
||||||
|
} else {
|
||||||
|
await sysParamsApi.createParam({ key, ...payload })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
upsert(registerParamKeys.mode, {
|
||||||
|
name: '注册模式',
|
||||||
|
desc: 'closed|open|invite',
|
||||||
|
value: values.mode,
|
||||||
|
}),
|
||||||
|
upsert(registerParamKeys.requireCaptcha, {
|
||||||
|
name: '注册验证码开关',
|
||||||
|
desc: '0/1(1 表示注册需要验证码)',
|
||||||
|
value: values.requireCaptcha ? '1' : '0',
|
||||||
|
}),
|
||||||
|
upsert(registerParamKeys.inviteExpireHours, {
|
||||||
|
name: '邀请码过期小时数',
|
||||||
|
desc: '12|24|72|168|720(单位:小时)',
|
||||||
|
value: String(values.inviteExpireHours),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
message.success('注册策略已保存')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card className="glass-panel page-panel" loading={loading}>
|
||||||
|
<div className="section-heading" style={{ marginBottom: 8 }}>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={3} style={{ marginBottom: 0 }}>
|
||||||
|
玩家注册策略
|
||||||
|
</Typography.Title>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={load} loading={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={save} loading={loading}>
|
||||||
|
保存策略
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ mode: 'closed', requireCaptcha: false, inviteExpireHours: 24 }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12} lg={10}>
|
||||||
|
<Form.Item name="mode" label="玩家注册模式" rules={[{ required: true }]}>
|
||||||
|
<Radio.Group
|
||||||
|
options={[
|
||||||
|
{ label: '关闭(closed)', value: 'closed' },
|
||||||
|
{ label: '开放(open)', value: 'open' },
|
||||||
|
{ label: '邀请码(invite)', value: 'invite' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12} lg={6}>
|
||||||
|
<Form.Item name="requireCaptcha" label="玩家注册需要验证码" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12} lg={8}>
|
||||||
|
<Form.Item name="inviteExpireHours" label="邀请码过期时间(小时)" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ label: '12 小时', value: 12 },
|
||||||
|
{ label: '24 小时', value: 24 },
|
||||||
|
{ label: '3 天(72 小时)', value: 72 },
|
||||||
|
{ label: '7 天(168 小时)', value: 168 },
|
||||||
|
{ label: '30 天(720 小时)', value: 720 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
259
web-admin/src/features/riskTags/RiskTagsPage.tsx
Normal file
259
web-admin/src/features/riskTags/RiskTagsPage.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Button, Card, ColorPicker, Form, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { appRiskTagsAdminApi } from '@/lib/api'
|
||||||
|
import { formatDate } from '@/lib/date'
|
||||||
|
import type { AppRiskTagRecord } from '@/types/system'
|
||||||
|
|
||||||
|
type EditValues = {
|
||||||
|
name: string
|
||||||
|
level: number
|
||||||
|
color?: string
|
||||||
|
desc?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RiskTagsPage() {
|
||||||
|
const [searchForm] = Form.useForm<{ keyword?: string }>()
|
||||||
|
const [editForm] = Form.useForm<EditValues>()
|
||||||
|
const [rows, setRows] = useState<AppRiskTagRecord[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<AppRiskTagRecord | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await appRiskTagsAdminApi.getTags({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
keyword: (searchForm.getFieldValue('keyword') || '').trim() || undefined,
|
||||||
|
})
|
||||||
|
setRows(res.data.list)
|
||||||
|
setTotal(res.data.total)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, pageSize, searchForm])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null)
|
||||||
|
editForm.resetFields()
|
||||||
|
editForm.setFieldsValue({ level: 1, color: '#cf1322' })
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (r: AppRiskTagRecord) => {
|
||||||
|
setEditing(r)
|
||||||
|
editForm.setFieldsValue({ name: r.name, level: r.level, color: r.color, desc: r.desc })
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const v = await editForm.validateFields()
|
||||||
|
const payload: EditValues = {
|
||||||
|
...v,
|
||||||
|
color:
|
||||||
|
typeof v.color === 'string'
|
||||||
|
? v.color
|
||||||
|
: // ColorPicker 可能返回 Color 对象,这里统一转成 hex 字符串
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
((v.color as any)?.toHexString?.() as string | undefined) ?? undefined,
|
||||||
|
}
|
||||||
|
if (editing) {
|
||||||
|
await appRiskTagsAdminApi.updateTag(editing.id, payload)
|
||||||
|
message.success('已更新')
|
||||||
|
} else {
|
||||||
|
await appRiskTagsAdminApi.createTag(payload)
|
||||||
|
message.success('已创建')
|
||||||
|
}
|
||||||
|
setModalOpen(false)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const del = (r: AppRiskTagRecord) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `删除标签「${r.name}」?`,
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
await appRiskTagsAdminApi.deleteTag(r.id)
|
||||||
|
message.success('已删除')
|
||||||
|
load()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelColor = useMemo(() => {
|
||||||
|
return (lvl: number) => {
|
||||||
|
if (lvl >= 5) return 'red'
|
||||||
|
if (lvl === 4) return 'volcano'
|
||||||
|
if (lvl === 3) return 'gold'
|
||||||
|
if (lvl === 2) return 'blue'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const columns: ColumnsType<AppRiskTagRecord> = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 90 },
|
||||||
|
{
|
||||||
|
title: '标签名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 200,
|
||||||
|
render: (v, r) => <Tag color={r.color || levelColor(r.level)}>{String(v)}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '级别',
|
||||||
|
dataIndex: 'level',
|
||||||
|
width: 110,
|
||||||
|
render: (v: number) => <Tag color={levelColor(v)}>{`L${v}`}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '说明', dataIndex: 'desc', ellipsis: true },
|
||||||
|
{ title: '创建时间', width: 190, render: (_, r) => formatDate(r.createdAt) },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" onClick={() => openEdit(r)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button danger type="link" onClick={() => del(r)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<div className="section-heading" style={{ marginBottom: 8 }}>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={3} style={{ marginBottom: 0 }}>
|
||||||
|
风险标签
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||||
|
人工版框架:用于给玩家打标,后续可扩展批量处置策略。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={load} loading={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新建标签
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={searchForm}
|
||||||
|
layout="inline"
|
||||||
|
onFinish={() => {
|
||||||
|
setPage(1)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="keyword" label="关键字">
|
||||||
|
<Input allowClear placeholder="标签名" style={{ width: 220 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
searchForm.resetFields()
|
||||||
|
setPage(1)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p)
|
||||||
|
setPageSize(ps)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal open={modalOpen} title={editing ? '编辑标签' : '新建标签'} onCancel={() => setModalOpen(false)} onOk={save}>
|
||||||
|
<Form form={editForm} layout="vertical" initialValues={{ level: 1 }}>
|
||||||
|
<Form.Item name="name" label="标签名" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="例如:疑似套利/高风险/人工复核" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="level"
|
||||||
|
label="风险级别(1-5)"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请选择风险级别' },
|
||||||
|
{
|
||||||
|
validator: async (_, value) => {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n) || n < 1 || n > 5) throw new Error('风险级别只能为 1-5')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="选择 1-5"
|
||||||
|
options={[
|
||||||
|
{ label: '1(低)', value: 1 },
|
||||||
|
{ label: '2', value: 2 },
|
||||||
|
{ label: '3(中)', value: 3 },
|
||||||
|
{ label: '4', value: 4 },
|
||||||
|
{ label: '5(高)', value: 5 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="color"
|
||||||
|
label="颜色(可选)"
|
||||||
|
// 将 ColorPicker 的值规范化为 hex 字符串(提交后端用)
|
||||||
|
getValueFromEvent={(color: any) => (typeof color === 'string' ? color : color?.toHexString?.())}
|
||||||
|
>
|
||||||
|
<ColorPicker
|
||||||
|
showText
|
||||||
|
format="hex"
|
||||||
|
presets={[
|
||||||
|
{
|
||||||
|
label: '常用',
|
||||||
|
colors: ['#1677ff', '#2f54eb', '#13c2c2', '#52c41a', '#faad14', '#fa8c16', '#f5222d', '#cf1322', '#722ed1', '#262626'],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="desc" label="说明(可选)">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -174,14 +174,14 @@ export function RoleManagementPage() {
|
|||||||
|
|
||||||
const defaultRouterOptions = useMemo(
|
const defaultRouterOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
collectMenusByIds(menuTree, new Set([...menuChecked, ...menuHalfChecked]))
|
collectMenusByIds(menuTree, new Set(menuChecked))
|
||||||
.filter((menu) => menu.name && !menu.name.startsWith('http://') && !menu.name.startsWith('https://'))
|
.filter((menu) => menu.name && !menu.name.startsWith('http://') && !menu.name.startsWith('https://'))
|
||||||
.filter((menu) => !(menu.children || []).length)
|
.filter((menu) => !(menu.children || []).length)
|
||||||
.map((menu) => ({
|
.map((menu) => ({
|
||||||
label: menu.meta.title,
|
label: menu.meta.title,
|
||||||
value: menu.name,
|
value: menu.name,
|
||||||
})),
|
})),
|
||||||
[menuChecked, menuHalfChecked, menuTree],
|
[menuChecked, menuTree],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -253,15 +253,14 @@ export function RoleManagementPage() {
|
|||||||
defaultRouter,
|
defaultRouter,
|
||||||
(menu) => void openButtonModal(menu),
|
(menu) => void openButtonModal(menu),
|
||||||
(menu) => {
|
(menu) => {
|
||||||
const checkedSet = new Set([...menuChecked, ...menuHalfChecked])
|
if (!new Set(menuChecked).has(menu.ID)) {
|
||||||
if (!checkedSet.has(menu.ID)) {
|
|
||||||
message.warning('请先勾选菜单,再将其设为首页')
|
message.warning('请先勾选菜单,再将其设为首页')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDefaultRouter(menu.name)
|
setDefaultRouter(menu.name)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
[defaultRouter, filteredMenus, menuChecked, menuHalfChecked, openButtonModal],
|
[defaultRouter, filteredMenus, menuChecked, openButtonModal],
|
||||||
)
|
)
|
||||||
const apiTreeData = useMemo(() => buildApiTree(apis, apiNameFilter, apiPathFilter), [apiNameFilter, apiPathFilter, apis])
|
const apiTreeData = useMemo(() => buildApiTree(apis, apiNameFilter, apiPathFilter), [apiNameFilter, apiPathFilter, apis])
|
||||||
|
|
||||||
@@ -394,7 +393,7 @@ export function RoleManagementPage() {
|
|||||||
setSavingPermission(true)
|
setSavingPermission(true)
|
||||||
try {
|
try {
|
||||||
if (activeTab === 'menus') {
|
if (activeTab === 'menus') {
|
||||||
const checkedMenus = collectMenusByIds(menuTree, new Set([...menuChecked, ...menuHalfChecked]))
|
const checkedMenus = collectMenusByIds(menuTree, new Set(menuChecked))
|
||||||
await menuApi.addMenuAuthority({
|
await menuApi.addMenuAuthority({
|
||||||
authorityId: activeRole.authorityId,
|
authorityId: activeRole.authorityId,
|
||||||
menus: checkedMenus,
|
menus: checkedMenus,
|
||||||
@@ -601,8 +600,9 @@ export function RoleManagementPage() {
|
|||||||
/>
|
/>
|
||||||
<Tree
|
<Tree
|
||||||
checkable
|
checkable
|
||||||
|
checkStrictly
|
||||||
defaultExpandAll
|
defaultExpandAll
|
||||||
checkedKeys={menuChecked}
|
checkedKeys={{ checked: menuChecked, halfChecked: menuHalfChecked }}
|
||||||
treeData={menuTreeData}
|
treeData={menuTreeData}
|
||||||
onCheck={(checkedKeys, info) => {
|
onCheck={(checkedKeys, info) => {
|
||||||
if (Array.isArray(checkedKeys)) {
|
if (Array.isArray(checkedKeys)) {
|
||||||
|
|||||||
406
web-admin/src/features/withdrawOrders/WithdrawOrdersPage.tsx
Normal file
406
web-admin/src/features/withdrawOrders/WithdrawOrdersPage.tsx
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Button, Card, Col, DatePicker, Drawer, Form, Input, Modal, Row, Select, Space, Table, Tag, Typography } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { appWithdrawOrdersAdminApi } from '@/lib/api'
|
||||||
|
import { formatDate } from '@/lib/date'
|
||||||
|
import type { AppWithdrawOrderRecord } from '@/types/system'
|
||||||
|
|
||||||
|
type Search = {
|
||||||
|
userId?: string
|
||||||
|
username?: string
|
||||||
|
status?: string
|
||||||
|
range?: [dayjs.Dayjs, dayjs.Dayjs]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLiToYuan(li?: number) {
|
||||||
|
const n = typeof li === 'number' && Number.isFinite(li) ? li : 0
|
||||||
|
return (n / 1000).toFixed(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(input?: string) {
|
||||||
|
const raw = (input ?? '').trim()
|
||||||
|
if (!raw) return undefined
|
||||||
|
const n = Number(raw)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return undefined
|
||||||
|
return Math.floor(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s?: string) {
|
||||||
|
const map: Record<string, { text: string; color?: string }> = {
|
||||||
|
pending: { text: '待审核', color: 'gold' },
|
||||||
|
approved: { text: '待打款', color: 'blue' },
|
||||||
|
rejected: { text: '已拒绝', color: 'default' },
|
||||||
|
paid: { text: '已打款', color: 'green' },
|
||||||
|
failed: { text: '打款失败', color: 'red' },
|
||||||
|
}
|
||||||
|
return map[String(s)] ?? { text: String(s ?? '-'), color: 'default' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WithdrawOrdersPage() {
|
||||||
|
const [form] = Form.useForm<Search>()
|
||||||
|
const [rows, setRows] = useState<AppWithdrawOrderRecord[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
const [drawerLoading, setDrawerLoading] = useState(false)
|
||||||
|
const [drawerRecord, setDrawerRecord] = useState<any>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
const range = values.range
|
||||||
|
const startAt = range?.[0]?.toISOString()
|
||||||
|
const endAt = range?.[1]?.toISOString()
|
||||||
|
const res = await appWithdrawOrdersAdminApi.getList({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
userId: parsePositiveInt(values.userId),
|
||||||
|
username: values.username?.trim() || undefined,
|
||||||
|
status: values.status || undefined,
|
||||||
|
...(startAt ? { startAt } : {}),
|
||||||
|
...(endAt ? { endAt } : {}),
|
||||||
|
})
|
||||||
|
setRows(res.data.list)
|
||||||
|
setTotal(res.data.total)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [form, page, pageSize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const openDetail = async (record: AppWithdrawOrderRecord) => {
|
||||||
|
setDrawerOpen(true)
|
||||||
|
setDrawerLoading(true)
|
||||||
|
try {
|
||||||
|
const detail = await appWithdrawOrdersAdminApi.getById(record.id)
|
||||||
|
setDrawerRecord(detail.data)
|
||||||
|
} finally {
|
||||||
|
setDrawerLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doAction = async (title: string, action: () => Promise<any>) => {
|
||||||
|
await action()
|
||||||
|
await load()
|
||||||
|
Modal.success({ title, content: '操作成功' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<AppWithdrawOrderRecord> = useMemo(
|
||||||
|
() => [
|
||||||
|
{ title: '订单ID', dataIndex: 'id', width: 100 },
|
||||||
|
{ title: '玩家ID', dataIndex: 'appUserId', width: 100 },
|
||||||
|
{ title: '用户名', dataIndex: 'username', width: 180 },
|
||||||
|
{
|
||||||
|
title: '提现金额(元)',
|
||||||
|
dataIndex: 'amountLi',
|
||||||
|
width: 130,
|
||||||
|
render: (v: number) => formatLiToYuan(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '手续费(元)',
|
||||||
|
dataIndex: 'feeLi',
|
||||||
|
width: 120,
|
||||||
|
render: (v: number) => formatLiToYuan(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '实付(元)',
|
||||||
|
dataIndex: 'netLi',
|
||||||
|
width: 120,
|
||||||
|
render: (v: number) => formatLiToYuan(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => {
|
||||||
|
const s = statusLabel(v)
|
||||||
|
return <Tag color={s.color}>{s.text}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '申请时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 190,
|
||||||
|
render: (v: string) => formatDate(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 260,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={6} wrap>
|
||||||
|
<Button type="link" style={{ padding: 0 }} onClick={() => openDetail(r)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
{r.status === 'pending' ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ padding: 0, color: '#1677ff' }}
|
||||||
|
onClick={() =>
|
||||||
|
Modal.confirm({
|
||||||
|
title: '审核通过',
|
||||||
|
content: '确认将订单置为“待打款”?',
|
||||||
|
onOk: () => doAction('审核通过', () => appWithdrawOrdersAdminApi.approve(r.id)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
onClick={() => {
|
||||||
|
let rejectReason = ''
|
||||||
|
let auditRemark = ''
|
||||||
|
Modal.confirm({
|
||||||
|
title: '审核拒绝(将解冻)',
|
||||||
|
content: (
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
<Input placeholder="拒绝原因(必填)" onChange={(e) => (rejectReason = e.target.value)} />
|
||||||
|
<Input placeholder="审核备注(可选)" onChange={(e) => (auditRemark = e.target.value)} />
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
onOk: async () => {
|
||||||
|
const reason = rejectReason.trim()
|
||||||
|
if (!reason) throw new Error('请填写拒绝原因')
|
||||||
|
await doAction('已拒绝并解冻', () => appWithdrawOrdersAdminApi.reject(r.id, { rejectReason: reason, auditRemark: auditRemark.trim() || undefined }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{r.status === 'approved' ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ padding: 0, color: '#389e0d' }}
|
||||||
|
onClick={() =>
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认已打款',
|
||||||
|
content: '确认将订单置为“已打款”(将扣减余额并解冻)?',
|
||||||
|
onOk: () => doAction('已确认打款', () => appWithdrawOrdersAdminApi.markPaid(r.id)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
已打款
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
onClick={() => {
|
||||||
|
let failureReason = ''
|
||||||
|
let externalTxId = ''
|
||||||
|
Modal.confirm({
|
||||||
|
title: '标记打款失败(将解冻)',
|
||||||
|
content: (
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
<Input placeholder="失败原因(必填)" onChange={(e) => (failureReason = e.target.value)} />
|
||||||
|
<Input placeholder="外部流水号(可选)" onChange={(e) => (externalTxId = e.target.value)} />
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
onOk: async () => {
|
||||||
|
const reason = failureReason.trim()
|
||||||
|
if (!reason) throw new Error('请填写失败原因')
|
||||||
|
await doAction('已标记失败并解冻', () => appWithdrawOrdersAdminApi.markFailed(r.id, { failureReason: reason, externalTxId: externalTxId.trim() || undefined }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
打款失败
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={() => {
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 8]}>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||||
|
<Form.Item name="userId" label="玩家ID" style={{ marginBottom: 0 }}>
|
||||||
|
<Input allowClear placeholder="输入玩家ID" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||||
|
<Form.Item name="username" label="用户名" style={{ marginBottom: 0 }}>
|
||||||
|
<Input allowClear placeholder="输入用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||||
|
<Form.Item name="status" label="状态" style={{ marginBottom: 0 }}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部"
|
||||||
|
options={[
|
||||||
|
{ label: '待审核', value: 'pending' },
|
||||||
|
{ label: '待打款', value: 'approved' },
|
||||||
|
{ label: '已拒绝', value: 'rejected' },
|
||||||
|
{ label: '已打款', value: 'paid' },
|
||||||
|
{ label: '打款失败', value: 'failed' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12} xl={8}>
|
||||||
|
<Form.Item name="range" label="申请时间" style={{ marginBottom: 0 }}>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
showTime
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
presets={[
|
||||||
|
{ label: '近24小时', value: [dayjs().subtract(24, 'hour'), dayjs()] },
|
||||||
|
{ label: '近7天', value: [dayjs().subtract(7, 'day'), dayjs()] },
|
||||||
|
{ label: '近30天', value: [dayjs().subtract(30, 'day'), dayjs()] },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12} xl={4}>
|
||||||
|
<Form.Item label=" " style={{ marginBottom: 0 }}>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields()
|
||||||
|
setPage(1)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
scroll={{ x: 1400 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={drawerOpen}
|
||||||
|
width={640}
|
||||||
|
title={drawerRecord ? `提现订单 #${drawerRecord.id}` : '提现订单'}
|
||||||
|
onClose={() => {
|
||||||
|
setDrawerOpen(false)
|
||||||
|
setDrawerRecord(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{drawerLoading ? (
|
||||||
|
<Typography.Paragraph>加载中...</Typography.Paragraph>
|
||||||
|
) : drawerRecord ? (
|
||||||
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<Card size="small" styles={{ body: { padding: 12 } }}>
|
||||||
|
<Row gutter={[12, 10]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Tag color="default">玩家</Tag>
|
||||||
|
<Typography.Text strong>
|
||||||
|
{drawerRecord.username}(#{drawerRecord.appUserId})
|
||||||
|
</Typography.Text>
|
||||||
|
<Tag color={statusLabel(drawerRecord.status).color}>{statusLabel(drawerRecord.status).text}</Tag>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Row gutter={[12, 10]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Typography.Text type="secondary">提现金额</Typography.Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 18 }}>
|
||||||
|
{formatLiToYuan(drawerRecord.amountLi)}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary"> 元</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Typography.Text type="secondary">手续费</Typography.Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Typography.Text>{formatLiToYuan(drawerRecord.feeLi)} 元</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Typography.Text type="secondary">实付</Typography.Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Typography.Text>{formatLiToYuan(drawerRecord.netLi)} 元</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title="订单信息">
|
||||||
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||||
|
<Typography.Text type="secondary">申请时间</Typography.Text>
|
||||||
|
<Typography.Text>{formatDate(drawerRecord.createdAt)}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">审核时间</Typography.Text>
|
||||||
|
<Typography.Text>{drawerRecord.approvedAt ? formatDate(drawerRecord.approvedAt) : '-'}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">打款时间</Typography.Text>
|
||||||
|
<Typography.Text>{drawerRecord.paidAt ? formatDate(drawerRecord.paidAt) : '-'}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">备注/原因</Typography.Text>
|
||||||
|
<Typography.Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{drawerRecord.rejectReason || drawerRecord.failureReason || drawerRecord.auditRemark || drawerRecord.applyRemark || '-'}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Paragraph>暂无数据</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user