From c6354ee06542a177aff2ab96e79eb69143cce3f7 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 23 Apr 2026 15:29:07 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=87=A0?= =?UTF-8?q?=E5=A4=A7=E4=B8=AD=E5=BF=83=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AntiCAP | 1 + server/api/v1/admin/app_asset_transactions.go | 234 +++ server/api/v1/admin/app_invite_codes.go | 301 ++++ server/api/v1/admin/app_login_logs.go | 138 ++ server/api/v1/admin/app_risk_tags.go | 283 ++++ server/api/v1/admin/app_users.go | 704 +++++++++ server/api/v1/admin/app_withdraw_orders.go | 528 +++++++ server/api/v1/admin/dashboard.go | 22 + server/api/v1/admin/enter.go | 11 + server/api/v1/app/app_auth.go | 37 + server/api/v1/app/enter.go | 7 + server/api/v1/app/invite_code.go | 50 + server/api/v1/app/public_auth.go | 122 ++ server/api/v1/enter.go | 4 + server/api/v1/system/enter.go | 4 + server/api/v1/system/invite_code.go | 63 + server/api/v1/system/public_auth.go | 28 + server/api/v1/system/sys_params.go | 112 ++ server/config.debug.yaml | 251 ++++ server/docs/docs.go | 1140 +++++++++++++- server/docs/swagger.json | 1140 +++++++++++++- server/docs/swagger.yaml | 683 ++++++++- server/global/global.go | 14 +- server/initialize/ensure_tables.go | 1 + server/initialize/gorm.go | 16 + server/initialize/router.go | 58 +- server/initialize/router_biz.go | 39 + server/middleware/app_activity.go | 34 + server/middleware/app_jwt.go | 74 + server/middleware/cors.go | 16 +- server/middleware/jwt.go | 8 + server/model/admin/dashboard_stats.go | 67 + server/model/app/app_asset_transaction.go | 35 + server/model/app/app_invite_code.go | 30 + server/model/app/app_login_log.go | 18 + server/model/app/app_risk_tag.go | 19 + server/model/app/app_user.go | 23 + server/model/app/app_user_activity.go | 16 + server/model/app/app_user_asset.go | 16 + server/model/app/app_user_invite_relation.go | 15 + server/model/app/app_user_profile.go | 18 + server/model/app/app_user_risk_tag.go | 19 + server/model/app/app_withdraw_order.go | 48 + server/model/app/request/login.go | 8 + server/model/app/request/public_register.go | 12 + server/model/app/response/register_status.go | 16 + server/model/system/invite_code.go | 32 + .../model/system/request/public_register.go | 15 + server/model/system/request/sys_user.go | 14 +- .../model/system/response/register_status.go | 16 + server/model/system/sys_param_change_log.go | 25 + server/model/system/sys_user.go | 25 +- server/model/system/user_invite_relation.go | 16 + server/router/app/app_auth.go | 18 + server/router/app/enter.go | 7 + server/router/app/invite_code.go | 18 + server/router/app/public_auth.go | 20 + server/router/enter.go | 2 + server/router/system/enter.go | 4 + server/router/system/invite_code.go | 19 + server/router/system/public_auth.go | 14 + server/router/system/sys_params.go | 7 +- server/service/app/activity.go | 40 + server/service/app/auth.go | 31 + server/service/app/enter.go | 7 + server/service/app/invite_code.go | 132 ++ server/service/app/register.go | 68 + server/service/app/register_config.go | 15 + server/service/enter.go | 2 + server/service/system/app_online_config.go | 30 + server/service/system/enter.go | 2 + server/service/system/invite_code.go | 144 ++ server/service/system/public_register.go | 70 + server/service/system/register_config.go | 84 ++ server/service/system/sys_api.go | 14 +- server/source/common/file_upload_download.go | 1 + server/source/system/api.go | 147 +- server/source/system/casbin.go | 101 ++ server/source/system/menu_repair.go | 223 +++ server/source/system/user.go | 19 +- server/utils/claims.go | 2 +- server/utils/jwt.go | 17 +- web-admin/package-lock.json | 1317 ++++++++++++++++- web-admin/package.json | 1 + web-admin/src/assets/avatar_user.jpg | Bin 0 -> 1262 bytes .../AppAssetTransactionsPage.tsx | 393 +++++ .../appLoginLogs/AppLoginLogsPage.tsx | 207 +++ .../features/appPlayers/AppPlayersPage.tsx | 576 +++++++ .../AssetPolicyCenterPage.tsx | 332 +++++ .../src/features/discovery/systemInventory.ts | 45 + .../inviteCodes/AdminInviteCodesPage.tsx | 363 +++++ web-admin/src/features/layout/AdminShell.tsx | 49 +- .../src/features/menus/MenuManagementPage.tsx | 34 +- .../features/menus/menuComponentCatalog.ts | 42 + .../features/params/ParamsManagementPage.tsx | 1 + .../features/params/SysParamChangeLogPage.tsx | 154 ++ .../registerPolicy/RegisterPolicyPage.tsx | 153 ++ .../src/features/riskTags/RiskTagsPage.tsx | 259 ++++ .../src/features/roles/RoleManagementPage.tsx | 14 +- .../withdrawOrders/WithdrawOrdersPage.tsx | 406 +++++ web-admin/src/index.css | 62 +- web-admin/src/lib/api.ts | 173 +++ web-admin/src/lib/http.ts | 3 + .../appAssetTransactions/page.tsx | 8 + .../src/router/pages/financeCenter/page.tsx | 8 + .../financeCenter/withdrawManage/page.tsx | 8 + .../withdrawManage/withdrawOrders/page.tsx | 8 + .../src/router/pages/gameCenter/page.tsx | 8 + .../opsConfig/assetPolicyCenter/page.tsx | 8 + web-admin/src/router/pages/opsConfig/page.tsx | 8 + .../router/pages/opsConfig/riskTags/page.tsx | 8 + .../opsConfig/sysParamChangeLog/page.tsx | 7 + .../userCenter/appAssetTransactions/page.tsx | 8 + .../pages/userCenter/appLoginLog/page.tsx | 7 + .../pages/userCenter/appPlayers/page.tsx | 8 + .../pages/userCenter/inviteCodes/page.tsx | 8 + .../src/router/pages/userCenter/page.tsx | 8 + .../pages/userCenter/registerPolicy/page.tsx | 8 + web-admin/src/types/system.ts | 154 ++ 后台里程碑计划(无需用户前端).md | 200 +++ 玩家资产与提现规则分析.md | 168 +++ 玩家需求分析.md | 120 ++ 菜单与路由对照清单(2026-04-23).md | 75 + 123 files changed, 13074 insertions(+), 229 deletions(-) create mode 160000 AntiCAP create mode 100644 server/api/v1/admin/app_asset_transactions.go create mode 100644 server/api/v1/admin/app_invite_codes.go create mode 100644 server/api/v1/admin/app_login_logs.go create mode 100644 server/api/v1/admin/app_risk_tags.go create mode 100644 server/api/v1/admin/app_users.go create mode 100644 server/api/v1/admin/app_withdraw_orders.go create mode 100644 server/api/v1/admin/dashboard.go create mode 100644 server/api/v1/admin/enter.go create mode 100644 server/api/v1/app/app_auth.go create mode 100644 server/api/v1/app/enter.go create mode 100644 server/api/v1/app/invite_code.go create mode 100644 server/api/v1/app/public_auth.go create mode 100644 server/api/v1/system/invite_code.go create mode 100644 server/api/v1/system/public_auth.go create mode 100644 server/config.debug.yaml create mode 100644 server/middleware/app_activity.go create mode 100644 server/middleware/app_jwt.go create mode 100644 server/model/admin/dashboard_stats.go create mode 100644 server/model/app/app_asset_transaction.go create mode 100644 server/model/app/app_invite_code.go create mode 100644 server/model/app/app_login_log.go create mode 100644 server/model/app/app_risk_tag.go create mode 100644 server/model/app/app_user.go create mode 100644 server/model/app/app_user_activity.go create mode 100644 server/model/app/app_user_asset.go create mode 100644 server/model/app/app_user_invite_relation.go create mode 100644 server/model/app/app_user_profile.go create mode 100644 server/model/app/app_user_risk_tag.go create mode 100644 server/model/app/app_withdraw_order.go create mode 100644 server/model/app/request/login.go create mode 100644 server/model/app/request/public_register.go create mode 100644 server/model/app/response/register_status.go create mode 100644 server/model/system/invite_code.go create mode 100644 server/model/system/request/public_register.go create mode 100644 server/model/system/response/register_status.go create mode 100644 server/model/system/sys_param_change_log.go create mode 100644 server/model/system/user_invite_relation.go create mode 100644 server/router/app/app_auth.go create mode 100644 server/router/app/enter.go create mode 100644 server/router/app/invite_code.go create mode 100644 server/router/app/public_auth.go create mode 100644 server/router/system/invite_code.go create mode 100644 server/router/system/public_auth.go create mode 100644 server/service/app/activity.go create mode 100644 server/service/app/auth.go create mode 100644 server/service/app/enter.go create mode 100644 server/service/app/invite_code.go create mode 100644 server/service/app/register.go create mode 100644 server/service/app/register_config.go create mode 100644 server/service/system/app_online_config.go create mode 100644 server/service/system/invite_code.go create mode 100644 server/service/system/public_register.go create mode 100644 server/service/system/register_config.go create mode 100644 server/source/system/menu_repair.go create mode 100644 web-admin/src/assets/avatar_user.jpg create mode 100644 web-admin/src/features/appAssetTransactions/AppAssetTransactionsPage.tsx create mode 100644 web-admin/src/features/appLoginLogs/AppLoginLogsPage.tsx create mode 100644 web-admin/src/features/appPlayers/AppPlayersPage.tsx create mode 100644 web-admin/src/features/assetPolicyCenter/AssetPolicyCenterPage.tsx create mode 100644 web-admin/src/features/inviteCodes/AdminInviteCodesPage.tsx create mode 100644 web-admin/src/features/params/SysParamChangeLogPage.tsx create mode 100644 web-admin/src/features/registerPolicy/RegisterPolicyPage.tsx create mode 100644 web-admin/src/features/riskTags/RiskTagsPage.tsx create mode 100644 web-admin/src/features/withdrawOrders/WithdrawOrdersPage.tsx create mode 100644 web-admin/src/router/pages/financeCenter/appAssetTransactions/page.tsx create mode 100644 web-admin/src/router/pages/financeCenter/page.tsx create mode 100644 web-admin/src/router/pages/financeCenter/withdrawManage/page.tsx create mode 100644 web-admin/src/router/pages/financeCenter/withdrawManage/withdrawOrders/page.tsx create mode 100644 web-admin/src/router/pages/gameCenter/page.tsx create mode 100644 web-admin/src/router/pages/opsConfig/assetPolicyCenter/page.tsx create mode 100644 web-admin/src/router/pages/opsConfig/page.tsx create mode 100644 web-admin/src/router/pages/opsConfig/riskTags/page.tsx create mode 100644 web-admin/src/router/pages/opsConfig/sysParamChangeLog/page.tsx create mode 100644 web-admin/src/router/pages/userCenter/appAssetTransactions/page.tsx create mode 100644 web-admin/src/router/pages/userCenter/appLoginLog/page.tsx create mode 100644 web-admin/src/router/pages/userCenter/appPlayers/page.tsx create mode 100644 web-admin/src/router/pages/userCenter/inviteCodes/page.tsx create mode 100644 web-admin/src/router/pages/userCenter/page.tsx create mode 100644 web-admin/src/router/pages/userCenter/registerPolicy/page.tsx create mode 100644 后台里程碑计划(无需用户前端).md create mode 100644 玩家资产与提现规则分析.md create mode 100644 玩家需求分析.md create mode 100644 菜单与路由对照清单(2026-04-23).md diff --git a/AntiCAP b/AntiCAP new file mode 160000 index 0000000..672e7b1 --- /dev/null +++ b/AntiCAP @@ -0,0 +1 @@ +Subproject commit 672e7b1cd90440f7af20bf9613e22962eda6c7bb diff --git a/server/api/v1/admin/app_asset_transactions.go b/server/api/v1/admin/app_asset_transactions.go new file mode 100644 index 0000000..62f6dcc --- /dev/null +++ b/server/api/v1/admin/app_asset_transactions.go @@ -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) +} diff --git a/server/api/v1/admin/app_invite_codes.go b/server/api/v1/admin/app_invite_codes.go new file mode 100644 index 0000000..ff5e33d --- /dev/null +++ b/server/api/v1/admin/app_invite_codes.go @@ -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) +} diff --git a/server/api/v1/admin/app_login_logs.go b/server/api/v1/admin/app_login_logs.go new file mode 100644 index 0000000..1a27456 --- /dev/null +++ b/server/api/v1/admin/app_login_logs.go @@ -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) +} diff --git a/server/api/v1/admin/app_risk_tags.go b/server/api/v1/admin/app_risk_tags.go new file mode 100644 index 0000000..214f792 --- /dev/null +++ b/server/api/v1/admin/app_risk_tags.go @@ -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) +} + diff --git a/server/api/v1/admin/app_users.go b/server/api/v1/admin/app_users.go new file mode 100644 index 0000000..b2266b7 --- /dev/null +++ b/server/api/v1/admin/app_users.go @@ -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 "" +} diff --git a/server/api/v1/admin/app_withdraw_orders.go b/server/api/v1/admin/app_withdraw_orders.go new file mode 100644 index 0000000..efcfcfe --- /dev/null +++ b/server/api/v1/admin/app_withdraw_orders.go @@ -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) +} + diff --git a/server/api/v1/admin/dashboard.go b/server/api/v1/admin/dashboard.go new file mode 100644 index 0000000..d7bcd52 --- /dev/null +++ b/server/api/v1/admin/dashboard.go @@ -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) +} diff --git a/server/api/v1/admin/enter.go b/server/api/v1/admin/enter.go new file mode 100644 index 0000000..a2f3836 --- /dev/null +++ b/server/api/v1/admin/enter.go @@ -0,0 +1,11 @@ +package admin + +type ApiGroup struct { + DashboardApi + AppUsersApi + AppInviteCodesApi + AppLoginLogsApi + AppAssetTransactionsApi + AppWithdrawOrdersApi + AppRiskTagsApi +} diff --git a/server/api/v1/app/app_auth.go b/server/api/v1/app/app_auth.go new file mode 100644 index 0000000..0280aa8 --- /dev/null +++ b/server/api/v1/app/app_auth.go @@ -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) +} diff --git a/server/api/v1/app/enter.go b/server/api/v1/app/enter.go new file mode 100644 index 0000000..bc4a9fb --- /dev/null +++ b/server/api/v1/app/enter.go @@ -0,0 +1,7 @@ +package app + +type ApiGroup struct { + PublicAuthApi + AppAuthApi + InviteCodeApi +} diff --git a/server/api/v1/app/invite_code.go b/server/api/v1/app/invite_code.go new file mode 100644 index 0000000..0ed7269 --- /dev/null +++ b/server/api/v1/app/invite_code.go @@ -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) +} diff --git a/server/api/v1/app/public_auth.go b/server/api/v1/app/public_auth.go new file mode 100644 index 0000000..bd39d5c --- /dev/null +++ b/server/api/v1/app/public_auth.go @@ -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, + } +} diff --git a/server/api/v1/enter.go b/server/api/v1/enter.go index ea2b2eb..522bbb0 100644 --- a/server/api/v1/enter.go +++ b/server/api/v1/enter.go @@ -1,6 +1,8 @@ package v1 import ( + "git.echol.cn/loser/Go-Web-Template/server/api/v1/admin" + "git.echol.cn/loser/Go-Web-Template/server/api/v1/app" "git.echol.cn/loser/Go-Web-Template/server/api/v1/common" "git.echol.cn/loser/Go-Web-Template/server/api/v1/system" ) @@ -10,4 +12,6 @@ var ApiGroupApp = new(ApiGroup) type ApiGroup struct { SystemApiGroup system.ApiGroup CommonApiGroup common.ApiGroup + AdminApiGroup admin.ApiGroup + AppApiGroup app.ApiGroup } diff --git a/server/api/v1/system/enter.go b/server/api/v1/system/enter.go index 3e8defc..020dd6e 100644 --- a/server/api/v1/system/enter.go +++ b/server/api/v1/system/enter.go @@ -7,6 +7,8 @@ type ApiGroup struct { JwtApi BaseApi SystemApi + PublicAuthApi + InviteCodeApi CasbinApi SystemApiApi AuthorityApi @@ -41,4 +43,6 @@ var ( sysErrorService = service.ServiceGroupApp.SystemServiceGroup.SysErrorService loginLogService = service.ServiceGroupApp.SystemServiceGroup.LoginLogService apiTokenService = service.ServiceGroupApp.SystemServiceGroup.ApiTokenService + inviteCodeService = service.ServiceGroupApp.SystemServiceGroup.InviteCodeService + publicRegisterService = service.ServiceGroupApp.SystemServiceGroup.PublicRegisterService ) diff --git a/server/api/v1/system/invite_code.go b/server/api/v1/system/invite_code.go new file mode 100644 index 0000000..4e2f0d1 --- /dev/null +++ b/server/api/v1/system/invite_code.go @@ -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) +} diff --git a/server/api/v1/system/public_auth.go b/server/api/v1/system/public_auth.go new file mode 100644 index 0000000..16e3719 --- /dev/null +++ b/server/api/v1/system/public_auth.go @@ -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) +} diff --git a/server/api/v1/system/sys_params.go b/server/api/v1/system/sys_params.go index 34e21c5..4af4c00 100644 --- a/server/api/v1/system/sys_params.go +++ b/server/api/v1/system/sys_params.go @@ -1,10 +1,14 @@ package system import ( + "strconv" + "strings" + "git.echol.cn/loser/Go-Web-Template/server/global" "git.echol.cn/loser/Go-Web-Template/server/model/common/response" "git.echol.cn/loser/Go-Web-Template/server/model/system" systemReq "git.echol.cn/loser/Go-Web-Template/server/model/system/request" + "git.echol.cn/loser/Go-Web-Template/server/utils" "github.com/gin-gonic/gin" "go.uber.org/zap" ) @@ -33,6 +37,18 @@ func (sysParamsApi *SysParamsApi) CreateSysParams(c *gin.Context) { response.FailWithMessage("创建失败:"+err.Error(), c) return } + // write audit (best-effort) + operatorID := utils.GetUserID(c) + if operatorID > 0 { + _ = global.GVA_DB.Create(&system.SysParamChangeLog{ + ParamID: sysParams.ID, + Key: sysParams.Key, + Action: system.SysParamChangeActionCreate, + OldValue: "", + NewValue: sysParams.Value, + OperatorUserID: operatorID, + }).Error + } response.OkWithMessage("创建成功", c) } @@ -47,12 +63,26 @@ func (sysParamsApi *SysParamsApi) CreateSysParams(c *gin.Context) { // @Router /sysParams/deleteSysParams [delete] func (sysParamsApi *SysParamsApi) DeleteSysParams(c *gin.Context) { ID := c.Query("ID") + // read before delete for audit + var before system.SysParams + _ = global.GVA_DB.Where("id = ?", ID).First(&before).Error err := sysParamsService.DeleteSysParams(ID) if err != nil { global.GVA_LOG.Error("删除失败!", zap.Error(err)) response.FailWithMessage("删除失败:"+err.Error(), c) return } + operatorID := utils.GetUserID(c) + if operatorID > 0 && before.ID > 0 { + _ = global.GVA_DB.Create(&system.SysParamChangeLog{ + ParamID: before.ID, + Key: before.Key, + Action: system.SysParamChangeActionDelete, + OldValue: before.Value, + NewValue: "", + OperatorUserID: operatorID, + }).Error + } response.OkWithMessage("删除成功", c) } @@ -91,15 +121,97 @@ func (sysParamsApi *SysParamsApi) UpdateSysParams(c *gin.Context) { response.FailWithMessage(err.Error(), c) return } + // read before update for audit + var before system.SysParams + _ = global.GVA_DB.Where("id = ?", sysParams.ID).First(&before).Error err = sysParamsService.UpdateSysParams(sysParams) if err != nil { global.GVA_LOG.Error("更新失败!", zap.Error(err)) response.FailWithMessage("更新失败:"+err.Error(), c) return } + operatorID := utils.GetUserID(c) + if operatorID > 0 && before.ID > 0 { + _ = global.GVA_DB.Create(&system.SysParamChangeLog{ + ParamID: before.ID, + Key: before.Key, + Action: system.SysParamChangeActionUpdate, + OldValue: before.Value, + NewValue: sysParams.Value, + OperatorUserID: operatorID, + }).Error + } response.OkWithMessage("更新成功", c) } +// GetSysParamChangeLogList 分页获取参数变更审计 +// @Tags SysParams +// @Summary 分页获取参数变更审计 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param page query int false "页码" default(1) +// @Param pageSize query int false "每页大小" default(10) +// @Param key query string false "key(模糊)" +// @Param operatorUserId query int false "操作人ID" +// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功" +// @Router /sysParams/getChangeLogList [get] +func (sysParamsApi *SysParamsApi) GetSysParamChangeLogList(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 200 { + pageSize = 10 + } + + key := strings.TrimSpace(c.Query("key")) + var operatorID uint + if raw := strings.TrimSpace(c.Query("operatorUserId")); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + operatorID = uint(v) + } + } + + db := global.GVA_DB.Model(&system.SysParamChangeLog{}) + if key != "" { + db = db.Where("key LIKE ?", "%"+key+"%") + } + if operatorID > 0 { + db = db.Where("operator_user_id = ?", operatorID) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + response.FailWithMessage("获取失败:"+err.Error(), c) + return + } + + type row struct { + system.SysParamChangeLog + OperatorName string `json:"operatorName" gorm:"column:operator_name"` + } + var list []row + if err := db. + Joins("LEFT JOIN sys_users su ON su.id = sys_param_change_logs.operator_user_id"). + Select("sys_param_change_logs.*, su.username AS operator_name"). + Order("sys_param_change_logs.id desc"). + Limit(pageSize). + Offset((page - 1) * pageSize). + Scan(&list).Error; err != nil { + response.FailWithMessage("获取失败:"+err.Error(), c) + return + } + + response.OkWithDetailed(response.PageResult{ + List: list, + Total: total, + Page: page, + PageSize: pageSize, + }, "获取成功", c) +} + // FindSysParams 用id查询参数 // @Tags SysParams // @Summary 用id查询参数 diff --git a/server/config.debug.yaml b/server/config.debug.yaml new file mode 100644 index 0000000..401d5fb --- /dev/null +++ b/server/config.debug.yaml @@ -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 diff --git a/server/docs/docs.go b/server/docs/docs.go index b694c0c..08c3f2c 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -15,6 +15,677 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/admin/app-invite-codes": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppInviteCodesAdmin" + ], + "summary": "后台获取玩家邀请码列表", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "每页大小", + "name": "pageSize", + "in": "query" + }, + { + "type": "string", + "description": "状态 unused|used|revoked|expired", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "邀请人用户ID", + "name": "createdByUserId", + "in": "query" + }, + { + "type": "integer", + "description": "使用人用户ID", + "name": "usedByUserId", + "in": "query" + }, + { + "type": "string", + "description": "末4位", + "name": "codeLast4", + "in": "query" + } + ], + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/response.PageResult" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-invite-codes/clear-unused": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppInviteCodesAdmin" + ], + "summary": "强制清空用户未使用邀请码(批量作废)", + "parameters": [ + { + "description": "用户ID", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.clearUnusedRequest" + } + } + ], + "responses": { + "200": { + "description": "清空成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-invite-codes/issue": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppInviteCodesAdmin" + ], + "summary": "后台代用户生成邀请码(返回明文一次性)", + "parameters": [ + { + "description": "用户ID", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.issueInviteCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "生成成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.issueInviteCodeResponse" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-invite-codes/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppInviteCodesAdmin" + ], + "summary": "后台获取玩家邀请码详情", + "parameters": [ + { + "type": "integer", + "description": "邀请码ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.appInviteCodeDetailResponse" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-invite-codes/{id}/revoke": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppInviteCodesAdmin" + ], + "summary": "作废玩家邀请码", + "parameters": [ + { + "type": "integer", + "description": "邀请码ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "作废成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-users": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "后台获取玩家列表", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "每页大小", + "name": "pageSize", + "in": "query" + }, + { + "type": "string", + "description": "用户名关键字", + "name": "keyword", + "in": "query" + } + ], + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/response.PageResult" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "后台手动新增玩家", + "parameters": [ + { + "description": "玩家信息", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.createAppUserRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-users/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "后台获取玩家详情", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.appUserDetailItem" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "软删除玩家", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "编辑玩家(仅启用状态/欢迎词)", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新字段", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.updateAppUserRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-users/{id}/recharge": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "后台手动充值(账户余额/游戏币,单位:厘)", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "充值信息", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.rechargeRequest" + } + } + ], + "responses": { + "200": { + "description": "充值成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-users/{id}/reset-password": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "重置玩家密码", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "新密码", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.resetPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "重置成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-users/{id}/toggle-enable": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "启用/停用玩家", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "操作成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/api/createApi": { "post": { "security": [ @@ -1395,7 +2066,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/request.Login" + "$ref": "#/definitions/git_echol_cn_loser_Go-Web-Template_server_model_system_request.Login" } } ], @@ -2599,6 +3270,68 @@ const docTemplate = `{ } } }, + "/public/auth/register": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "PublicAuth" + ], + "summary": "(废弃)用户前端注册", + "responses": { + "200": { + "description": "已废弃", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/public/auth/register/status": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "PublicAuth" + ], + "summary": "(废弃)获取注册开放状态", + "responses": { + "200": { + "description": "已废弃", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/sysDictionary/createSysDictionary": { "post": { "security": [ @@ -5328,6 +6061,84 @@ const docTemplate = `{ } } }, + "/user/invite-code/current": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "InviteCode" + ], + "summary": "获取当前未使用邀请码(不返回明文)", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/system.inviteCodeCurrentResponse" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/user/invite-code/generate": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "InviteCode" + ], + "summary": "生成邀请码(同一时间仅允许一个未使用码)", + "responses": { + "200": { + "description": "生成成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/system.InviteCodeGenerated" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/user/resetPassword": { "post": { "security": [ @@ -5531,6 +6342,247 @@ const docTemplate = `{ } }, "definitions": { + "admin.appInviteCodeDetailResponse": { + "type": "object", + "properties": { + "code": { + "$ref": "#/definitions/app.AppInviteCode" + }, + "invitee": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "inviter": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "relation": { + "$ref": "#/definitions/app.AppUserInviteRelation" + } + } + }, + "admin.appUserDetailItem": { + "type": "object", + "properties": { + "enable": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "nickName": { + "type": "string" + }, + "username": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "welcomePhrase": { + "type": "string" + } + } + }, + "admin.clearUnusedRequest": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "scope": { + "description": "active|all", + "type": "string" + }, + "userId": { + "type": "integer" + } + } + }, + "admin.createAppUserRequest": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "enable": { + "type": "boolean" + }, + "nickName": { + "type": "string" + }, + "password": { + "type": "string", + "maxLength": 128, + "minLength": 6 + }, + "username": { + "type": "string", + "maxLength": 64, + "minLength": 3 + }, + "welcomePhrase": { + "type": "string" + } + } + }, + "admin.issueInviteCodeRequest": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "expireHours": { + "type": "integer" + }, + "userId": { + "type": "integer" + } + } + }, + "admin.issueInviteCodeResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "codeLast4": { + "type": "string" + }, + "expiresAt": { + "type": "string" + } + } + }, + "admin.rechargeRequest": { + "type": "object", + "required": [ + "assetType", + "deltaLi", + "reason", + "rechargeType" + ], + "properties": { + "assetType": { + "type": "string" + }, + "deltaLi": { + "type": "integer" + }, + "reason": { + "type": "string" + }, + "rechargeType": { + "type": "string" + } + } + }, + "admin.resetPasswordRequest": { + "type": "object", + "required": [ + "newPassword" + ], + "properties": { + "newPassword": { + "type": "string", + "maxLength": 128, + "minLength": 6 + } + } + }, + "admin.updateAppUserRequest": { + "type": "object", + "properties": { + "enable": { + "type": "boolean" + }, + "welcomePhrase": { + "type": "string" + } + } + }, + "app.AppInviteCode": { + "type": "object", + "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" + } + } + }, + "app.AppUserInviteRelation": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "inviteCodeId": { + "type": "integer" + }, + "inviteeUserId": { + "type": "integer" + }, + "inviterUserId": { + "type": "integer" + } + } + }, + "app.InviteCodeStatus": { + "type": "string", + "enum": [ + "unused", + "used", + "revoked", + "expired" + ], + "x-enum-varnames": [ + "InviteCodeStatusUnused", + "InviteCodeStatusUsed", + "InviteCodeStatusRevoked", + "InviteCodeStatusExpired" + ] + }, "common.ExaAttachmentCategory": { "type": "object", "properties": { @@ -5847,14 +6899,6 @@ const docTemplate = `{ } } }, - "config.Excel": { - "type": "object", - "properties": { - "dir": { - "type": "string" - } - } - }, "config.HuaWeiObs": { "type": "object", "properties": { @@ -5899,8 +6943,12 @@ const docTemplate = `{ "config.Local": { "type": "object", "properties": { + "base-url": { + "description": "文件访问域名前缀,如 https://api.wanjia.ai", + "type": "string" + }, "path": { - "description": "本地文件访问路径", + "description": "本地文件访问路径(相对路径)", "type": "string" }, "store-path": { @@ -6381,9 +7429,6 @@ const docTemplate = `{ "email": { "$ref": "#/definitions/config.Email" }, - "excel": { - "$ref": "#/definitions/config.Excel" - }, "hua-wei-obs": { "$ref": "#/definitions/config.HuaWeiObs" }, @@ -6690,6 +7735,27 @@ const docTemplate = `{ } } }, + "git_echol_cn_loser_Go-Web-Template_server_model_system_request.Login": { + "type": "object", + "properties": { + "captcha": { + "description": "验证码", + "type": "string" + }, + "captchaId": { + "description": "验证码ID", + "type": "string" + }, + "password": { + "description": "密码", + "type": "string" + }, + "username": { + "description": "用户名", + "type": "string" + } + } + }, "request.AddMenuAuthorityInfo": { "type": "object", "properties": { @@ -6891,27 +7957,6 @@ const docTemplate = `{ } } }, - "request.Login": { - "type": "object", - "properties": { - "captcha": { - "description": "验证码", - "type": "string" - }, - "captchaId": { - "description": "验证码ID", - "type": "string" - }, - "password": { - "description": "密码", - "type": "string" - }, - "username": { - "description": "用户名", - "type": "string" - } - } - }, "request.PageInfo": { "type": "object", "properties": { @@ -7305,6 +8350,20 @@ const docTemplate = `{ } } }, + "system.InviteCodeGenerated": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "codeLast4": { + "type": "string" + }, + "expiresAt": { + "type": "string" + } + } + }, "system.Meta": { "type": "object", "properties": { @@ -7953,6 +9012,10 @@ const docTemplate = `{ "uuid": { "description": "用户UUID", "type": "string" + }, + "welcomePhrase": { + "description": "安全欢迎词(用户自定义)", + "type": "string" } } }, @@ -7963,6 +9026,15 @@ const docTemplate = `{ "$ref": "#/definitions/config.Server" } } + }, + "system.inviteCodeCurrentResponse": { + "type": "object", + "properties": { + "hasUnused": { + "type": "boolean" + }, + "record": {} + } } }, "securityDefinitions": { diff --git a/server/docs/swagger.json b/server/docs/swagger.json index 16c9cdf..74d2a14 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -7,6 +7,677 @@ "version": "v2.9.1" }, "paths": { + "/admin/app-invite-codes": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppInviteCodesAdmin" + ], + "summary": "后台获取玩家邀请码列表", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "每页大小", + "name": "pageSize", + "in": "query" + }, + { + "type": "string", + "description": "状态 unused|used|revoked|expired", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "邀请人用户ID", + "name": "createdByUserId", + "in": "query" + }, + { + "type": "integer", + "description": "使用人用户ID", + "name": "usedByUserId", + "in": "query" + }, + { + "type": "string", + "description": "末4位", + "name": "codeLast4", + "in": "query" + } + ], + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/response.PageResult" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-invite-codes/clear-unused": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppInviteCodesAdmin" + ], + "summary": "强制清空用户未使用邀请码(批量作废)", + "parameters": [ + { + "description": "用户ID", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.clearUnusedRequest" + } + } + ], + "responses": { + "200": { + "description": "清空成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-invite-codes/issue": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppInviteCodesAdmin" + ], + "summary": "后台代用户生成邀请码(返回明文一次性)", + "parameters": [ + { + "description": "用户ID", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.issueInviteCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "生成成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.issueInviteCodeResponse" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-invite-codes/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppInviteCodesAdmin" + ], + "summary": "后台获取玩家邀请码详情", + "parameters": [ + { + "type": "integer", + "description": "邀请码ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.appInviteCodeDetailResponse" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-invite-codes/{id}/revoke": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppInviteCodesAdmin" + ], + "summary": "作废玩家邀请码", + "parameters": [ + { + "type": "integer", + "description": "邀请码ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "作废成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-users": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "后台获取玩家列表", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "每页大小", + "name": "pageSize", + "in": "query" + }, + { + "type": "string", + "description": "用户名关键字", + "name": "keyword", + "in": "query" + } + ], + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/response.PageResult" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "后台手动新增玩家", + "parameters": [ + { + "description": "玩家信息", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.createAppUserRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-users/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "后台获取玩家详情", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.appUserDetailItem" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "软删除玩家", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "编辑玩家(仅启用状态/欢迎词)", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新字段", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.updateAppUserRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-users/{id}/recharge": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "后台手动充值(账户余额/游戏币,单位:厘)", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "充值信息", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.rechargeRequest" + } + } + ], + "responses": { + "200": { + "description": "充值成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-users/{id}/reset-password": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "重置玩家密码", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "新密码", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.resetPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "重置成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/admin/app-users/{id}/toggle-enable": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AppUsersAdmin" + ], + "summary": "启用/停用玩家", + "parameters": [ + { + "type": "integer", + "description": "玩家ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "操作成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/api/createApi": { "post": { "security": [ @@ -1387,7 +2058,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/request.Login" + "$ref": "#/definitions/git_echol_cn_loser_Go-Web-Template_server_model_system_request.Login" } } ], @@ -2591,6 +3262,68 @@ } } }, + "/public/auth/register": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "PublicAuth" + ], + "summary": "(废弃)用户前端注册", + "responses": { + "200": { + "description": "已废弃", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/public/auth/register/status": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "PublicAuth" + ], + "summary": "(废弃)获取注册开放状态", + "responses": { + "200": { + "description": "已废弃", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/sysDictionary/createSysDictionary": { "post": { "security": [ @@ -5320,6 +6053,84 @@ } } }, + "/user/invite-code/current": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "InviteCode" + ], + "summary": "获取当前未使用邀请码(不返回明文)", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/system.inviteCodeCurrentResponse" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/user/invite-code/generate": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "InviteCode" + ], + "summary": "生成邀请码(同一时间仅允许一个未使用码)", + "responses": { + "200": { + "description": "生成成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/system.InviteCodeGenerated" + }, + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/user/resetPassword": { "post": { "security": [ @@ -5523,6 +6334,247 @@ } }, "definitions": { + "admin.appInviteCodeDetailResponse": { + "type": "object", + "properties": { + "code": { + "$ref": "#/definitions/app.AppInviteCode" + }, + "invitee": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "inviter": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "relation": { + "$ref": "#/definitions/app.AppUserInviteRelation" + } + } + }, + "admin.appUserDetailItem": { + "type": "object", + "properties": { + "enable": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "nickName": { + "type": "string" + }, + "username": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "welcomePhrase": { + "type": "string" + } + } + }, + "admin.clearUnusedRequest": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "scope": { + "description": "active|all", + "type": "string" + }, + "userId": { + "type": "integer" + } + } + }, + "admin.createAppUserRequest": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "enable": { + "type": "boolean" + }, + "nickName": { + "type": "string" + }, + "password": { + "type": "string", + "maxLength": 128, + "minLength": 6 + }, + "username": { + "type": "string", + "maxLength": 64, + "minLength": 3 + }, + "welcomePhrase": { + "type": "string" + } + } + }, + "admin.issueInviteCodeRequest": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "expireHours": { + "type": "integer" + }, + "userId": { + "type": "integer" + } + } + }, + "admin.issueInviteCodeResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "codeLast4": { + "type": "string" + }, + "expiresAt": { + "type": "string" + } + } + }, + "admin.rechargeRequest": { + "type": "object", + "required": [ + "assetType", + "deltaLi", + "reason", + "rechargeType" + ], + "properties": { + "assetType": { + "type": "string" + }, + "deltaLi": { + "type": "integer" + }, + "reason": { + "type": "string" + }, + "rechargeType": { + "type": "string" + } + } + }, + "admin.resetPasswordRequest": { + "type": "object", + "required": [ + "newPassword" + ], + "properties": { + "newPassword": { + "type": "string", + "maxLength": 128, + "minLength": 6 + } + } + }, + "admin.updateAppUserRequest": { + "type": "object", + "properties": { + "enable": { + "type": "boolean" + }, + "welcomePhrase": { + "type": "string" + } + } + }, + "app.AppInviteCode": { + "type": "object", + "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" + } + } + }, + "app.AppUserInviteRelation": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "inviteCodeId": { + "type": "integer" + }, + "inviteeUserId": { + "type": "integer" + }, + "inviterUserId": { + "type": "integer" + } + } + }, + "app.InviteCodeStatus": { + "type": "string", + "enum": [ + "unused", + "used", + "revoked", + "expired" + ], + "x-enum-varnames": [ + "InviteCodeStatusUnused", + "InviteCodeStatusUsed", + "InviteCodeStatusRevoked", + "InviteCodeStatusExpired" + ] + }, "common.ExaAttachmentCategory": { "type": "object", "properties": { @@ -5839,14 +6891,6 @@ } } }, - "config.Excel": { - "type": "object", - "properties": { - "dir": { - "type": "string" - } - } - }, "config.HuaWeiObs": { "type": "object", "properties": { @@ -5891,8 +6935,12 @@ "config.Local": { "type": "object", "properties": { + "base-url": { + "description": "文件访问域名前缀,如 https://api.wanjia.ai", + "type": "string" + }, "path": { - "description": "本地文件访问路径", + "description": "本地文件访问路径(相对路径)", "type": "string" }, "store-path": { @@ -6373,9 +7421,6 @@ "email": { "$ref": "#/definitions/config.Email" }, - "excel": { - "$ref": "#/definitions/config.Excel" - }, "hua-wei-obs": { "$ref": "#/definitions/config.HuaWeiObs" }, @@ -6682,6 +7727,27 @@ } } }, + "git_echol_cn_loser_Go-Web-Template_server_model_system_request.Login": { + "type": "object", + "properties": { + "captcha": { + "description": "验证码", + "type": "string" + }, + "captchaId": { + "description": "验证码ID", + "type": "string" + }, + "password": { + "description": "密码", + "type": "string" + }, + "username": { + "description": "用户名", + "type": "string" + } + } + }, "request.AddMenuAuthorityInfo": { "type": "object", "properties": { @@ -6883,27 +7949,6 @@ } } }, - "request.Login": { - "type": "object", - "properties": { - "captcha": { - "description": "验证码", - "type": "string" - }, - "captchaId": { - "description": "验证码ID", - "type": "string" - }, - "password": { - "description": "密码", - "type": "string" - }, - "username": { - "description": "用户名", - "type": "string" - } - } - }, "request.PageInfo": { "type": "object", "properties": { @@ -7297,6 +8342,20 @@ } } }, + "system.InviteCodeGenerated": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "codeLast4": { + "type": "string" + }, + "expiresAt": { + "type": "string" + } + } + }, "system.Meta": { "type": "object", "properties": { @@ -7945,6 +9004,10 @@ "uuid": { "description": "用户UUID", "type": "string" + }, + "welcomePhrase": { + "description": "安全欢迎词(用户自定义)", + "type": "string" } } }, @@ -7955,6 +9018,15 @@ "$ref": "#/definitions/config.Server" } } + }, + "system.inviteCodeCurrentResponse": { + "type": "object", + "properties": { + "hasUnused": { + "type": "boolean" + }, + "record": {} + } } }, "securityDefinitions": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index 8531bbf..87d8780 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -1,4 +1,166 @@ definitions: + admin.appInviteCodeDetailResponse: + properties: + code: + $ref: '#/definitions/app.AppInviteCode' + invitee: + properties: + id: + type: integer + username: + type: string + type: object + inviter: + properties: + id: + type: integer + username: + type: string + type: object + relation: + $ref: '#/definitions/app.AppUserInviteRelation' + type: object + admin.appUserDetailItem: + properties: + enable: + type: integer + id: + type: integer + nickName: + type: string + username: + type: string + uuid: + type: string + welcomePhrase: + type: string + type: object + admin.clearUnusedRequest: + properties: + scope: + description: active|all + type: string + userId: + type: integer + required: + - userId + type: object + admin.createAppUserRequest: + properties: + enable: + type: boolean + nickName: + type: string + password: + maxLength: 128 + minLength: 6 + type: string + username: + maxLength: 64 + minLength: 3 + type: string + welcomePhrase: + type: string + required: + - password + - username + type: object + admin.issueInviteCodeRequest: + properties: + expireHours: + type: integer + userId: + type: integer + required: + - userId + type: object + admin.issueInviteCodeResponse: + properties: + code: + type: string + codeLast4: + type: string + expiresAt: + type: string + type: object + admin.rechargeRequest: + properties: + assetType: + type: string + deltaLi: + type: integer + reason: + type: string + rechargeType: + type: string + required: + - assetType + - deltaLi + - reason + - rechargeType + type: object + admin.resetPasswordRequest: + properties: + newPassword: + maxLength: 128 + minLength: 6 + type: string + required: + - newPassword + type: object + admin.updateAppUserRequest: + properties: + enable: + type: boolean + welcomePhrase: + type: string + type: object + app.AppInviteCode: + properties: + codeLast4: + type: string + createdAt: + type: string + createdByUserId: + type: integer + expiresAt: + type: string + id: + type: integer + status: + $ref: '#/definitions/app.InviteCodeStatus' + updatedAt: + type: string + usedAt: + type: string + usedByUserId: + type: integer + type: object + app.AppUserInviteRelation: + properties: + createdAt: + type: string + id: + type: integer + inviteCodeId: + type: integer + inviteeUserId: + type: integer + inviterUserId: + type: integer + type: object + app.InviteCodeStatus: + enum: + - unused + - used + - revoked + - expired + type: string + x-enum-varnames: + - InviteCodeStatusUnused + - InviteCodeStatusUsed + - InviteCodeStatusRevoked + - InviteCodeStatusExpired common.ExaAttachmentCategory: properties: ID: @@ -216,11 +378,6 @@ definitions: description: 收件人:多个以英文逗号分隔 例:a@qq.com b@qq.com 正式开发中请把此项目作为参数使用 type: string type: object - config.Excel: - properties: - dir: - type: string - type: object config.HuaWeiObs: properties: access-key: @@ -251,8 +408,11 @@ definitions: type: object config.Local: properties: + base-url: + description: 文件访问域名前缀,如 https://api.wanjia.ai + type: string path: - description: 本地文件访问路径 + description: 本地文件访问路径(相对路径) type: string store-path: description: 本地文件存储路径 @@ -597,8 +757,6 @@ definitions: type: array email: $ref: '#/definitions/config.Email' - excel: - $ref: '#/definitions/config.Excel' hua-wei-obs: $ref: '#/definitions/config.HuaWeiObs' jwt: @@ -812,6 +970,21 @@ definitions: description: 栈名 type: string type: object + git_echol_cn_loser_Go-Web-Template_server_model_system_request.Login: + properties: + captcha: + description: 验证码 + type: string + captchaId: + description: 验证码ID + type: string + password: + description: 密码 + type: string + username: + description: 用户名 + type: string + type: object request.AddMenuAuthorityInfo: properties: authorityId: @@ -951,21 +1124,6 @@ definitions: - adminPassword - dbName type: object - request.Login: - properties: - captcha: - description: 验证码 - type: string - captchaId: - description: 验证码ID - type: string - password: - description: 密码 - type: string - username: - description: 用户名 - type: string - type: object request.PageInfo: properties: keyword: @@ -1231,6 +1389,15 @@ definitions: user: $ref: '#/definitions/system.SysUser' type: object + system.InviteCodeGenerated: + properties: + code: + type: string + codeLast4: + type: string + expiresAt: + type: string + type: object system.Meta: properties: activeName: @@ -1691,18 +1858,410 @@ definitions: uuid: description: 用户UUID type: string + welcomePhrase: + description: 安全欢迎词(用户自定义) + type: string type: object system.System: properties: config: $ref: '#/definitions/config.Server' type: object + system.inviteCodeCurrentResponse: + properties: + hasUnused: + type: boolean + record: {} + type: object info: contact: {} description: 使用gin+react进行极速开发的全栈开发基础平台 title: Gin-React-Admin Swagger API接口文档 version: v2.9.1 paths: + /admin/app-invite-codes: + get: + parameters: + - default: 1 + description: 页码 + in: query + name: page + type: integer + - default: 10 + description: 每页大小 + in: query + name: pageSize + type: integer + - description: 状态 unused|used|revoked|expired + in: query + name: status + type: string + - description: 邀请人用户ID + in: query + name: createdByUserId + type: integer + - description: 使用人用户ID + in: query + name: usedByUserId + type: integer + - description: 末4位 + in: query + name: codeLast4 + type: string + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + data: + $ref: '#/definitions/response.PageResult' + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 后台获取玩家邀请码列表 + tags: + - AppInviteCodesAdmin + /admin/app-invite-codes/{id}: + get: + parameters: + - description: 邀请码ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + data: + $ref: '#/definitions/admin.appInviteCodeDetailResponse' + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 后台获取玩家邀请码详情 + tags: + - AppInviteCodesAdmin + /admin/app-invite-codes/{id}/revoke: + post: + parameters: + - description: 邀请码ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 作废成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 作废玩家邀请码 + tags: + - AppInviteCodesAdmin + /admin/app-invite-codes/clear-unused: + post: + parameters: + - description: 用户ID + in: body + name: data + required: true + schema: + $ref: '#/definitions/admin.clearUnusedRequest' + produces: + - application/json + responses: + "200": + description: 清空成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 强制清空用户未使用邀请码(批量作废) + tags: + - AppInviteCodesAdmin + /admin/app-invite-codes/issue: + post: + parameters: + - description: 用户ID + in: body + name: data + required: true + schema: + $ref: '#/definitions/admin.issueInviteCodeRequest' + produces: + - application/json + responses: + "200": + description: 生成成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + data: + $ref: '#/definitions/admin.issueInviteCodeResponse' + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 后台代用户生成邀请码(返回明文一次性) + tags: + - AppInviteCodesAdmin + /admin/app-users: + get: + parameters: + - default: 1 + description: 页码 + in: query + name: page + type: integer + - default: 10 + description: 每页大小 + in: query + name: pageSize + type: integer + - description: 用户名关键字 + in: query + name: keyword + type: string + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + data: + $ref: '#/definitions/response.PageResult' + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 后台获取玩家列表 + tags: + - AppUsersAdmin + post: + parameters: + - description: 玩家信息 + in: body + name: data + required: true + schema: + $ref: '#/definitions/admin.createAppUserRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 后台手动新增玩家 + tags: + - AppUsersAdmin + /admin/app-users/{id}: + delete: + parameters: + - description: 玩家ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 软删除玩家 + tags: + - AppUsersAdmin + get: + parameters: + - description: 玩家ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + data: + $ref: '#/definitions/admin.appUserDetailItem' + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 后台获取玩家详情 + tags: + - AppUsersAdmin + patch: + parameters: + - description: 玩家ID + in: path + name: id + required: true + type: integer + - description: 更新字段 + in: body + name: data + required: true + schema: + $ref: '#/definitions/admin.updateAppUserRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 编辑玩家(仅启用状态/欢迎词) + tags: + - AppUsersAdmin + /admin/app-users/{id}/recharge: + post: + parameters: + - description: 玩家ID + in: path + name: id + required: true + type: integer + - description: 充值信息 + in: body + name: data + required: true + schema: + $ref: '#/definitions/admin.rechargeRequest' + produces: + - application/json + responses: + "200": + description: 充值成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 后台手动充值(账户余额/游戏币,单位:厘) + tags: + - AppUsersAdmin + /admin/app-users/{id}/reset-password: + post: + parameters: + - description: 玩家ID + in: path + name: id + required: true + type: integer + - description: 新密码 + in: body + name: data + required: true + schema: + $ref: '#/definitions/admin.resetPasswordRequest' + produces: + - application/json + responses: + "200": + description: 重置成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 重置玩家密码 + tags: + - AppUsersAdmin + /admin/app-users/{id}/toggle-enable: + post: + parameters: + - description: 玩家ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 操作成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 启用/停用玩家 + tags: + - AppUsersAdmin /api/createApi: post: consumes: @@ -2475,7 +3034,7 @@ paths: name: data required: true schema: - $ref: '#/definitions/request.Login' + $ref: '#/definitions/git_echol_cn_loser_Go-Web-Template_server_model_system_request.Login' produces: - application/json responses: @@ -3155,6 +3714,40 @@ paths: summary: 更新菜单 tags: - Menu + /public/auth/register: + post: + produces: + - application/json + responses: + "200": + description: 已废弃 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + msg: + type: string + type: object + summary: (废弃)用户前端注册 + tags: + - PublicAuth + /public/auth/register/status: + get: + produces: + - application/json + responses: + "200": + description: 已废弃 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + msg: + type: string + type: object + summary: (废弃)获取注册开放状态 + tags: + - PublicAuth /sysDictionary/createSysDictionary: post: consumes: @@ -4742,6 +5335,48 @@ paths: summary: 分页获取用户列表 tags: - SysUser + /user/invite-code/current: + get: + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + data: + $ref: '#/definitions/system.inviteCodeCurrentResponse' + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 获取当前未使用邀请码(不返回明文) + tags: + - InviteCode + /user/invite-code/generate: + post: + produces: + - application/json + responses: + "200": + description: 生成成功 + schema: + allOf: + - $ref: '#/definitions/response.Response' + - properties: + data: + $ref: '#/definitions/system.InviteCodeGenerated' + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: 生成邀请码(同一时间仅允许一个未使用码) + tags: + - InviteCode /user/resetPassword: post: parameters: diff --git a/server/global/global.go b/server/global/global.go index c5876db..456a2c5 100644 --- a/server/global/global.go +++ b/server/global/global.go @@ -36,10 +36,16 @@ var ( GVA_Timer timer.Timer = timer.NewTimerTask() GVA_Concurrency_Control = &singleflight.Group{} GVA_ROUTERS gin.RoutesInfo - GVA_ACTIVE_DBNAME *string - GVA_MCP_SERVER *server.MCPServer - BlackCache local_cache.Cache - lock sync.RWMutex + // GVA_API_DEFAULT_META 用于在“同步路由”时给新增 API 预填分组与简介 + // key: METHOD + " " + PATH (e.g. "GET /admin/app-users") + GVA_API_DEFAULT_META map[string]struct { + ApiGroup string + Description string + } + GVA_ACTIVE_DBNAME *string + GVA_MCP_SERVER *server.MCPServer + BlackCache local_cache.Cache + lock sync.RWMutex ) // GetGlobalDBByDBName 通过名称获取db list中的db diff --git a/server/initialize/ensure_tables.go b/server/initialize/ensure_tables.go index 4ee67d1..cf695d2 100644 --- a/server/initialize/ensure_tables.go +++ b/server/initialize/ensure_tables.go @@ -48,6 +48,7 @@ func (e *ensureTables) MigrateTable(ctx context.Context) (context.Context, error sysModel.SysBaseMenuBtn{}, sysModel.SysAuthorityBtn{}, sysModel.SysParams{}, + sysModel.SysParamChangeLog{}, sysModel.SysError{}, sysModel.SysLoginLog{}, sysModel.SysApiToken{}, diff --git a/server/initialize/gorm.go b/server/initialize/gorm.go index 91c73cf..51468c6 100644 --- a/server/initialize/gorm.go +++ b/server/initialize/gorm.go @@ -4,6 +4,7 @@ import ( "os" "git.echol.cn/loser/Go-Web-Template/server/global" + appModel "git.echol.cn/loser/Go-Web-Template/server/model/app" commonModel "git.echol.cn/loser/Go-Web-Template/server/model/common" "git.echol.cn/loser/Go-Web-Template/server/model/system" @@ -46,6 +47,8 @@ func RegisterTables() { system.SysApi{}, system.SysIgnoreApi{}, system.SysUser{}, + system.InviteCode{}, + system.UserInviteRelation{}, system.SysBaseMenu{}, system.JwtBlacklist{}, system.SysAuthority{}, @@ -56,6 +59,7 @@ func RegisterTables() { system.SysBaseMenuBtn{}, system.SysAuthorityBtn{}, system.SysParams{}, + system.SysParamChangeLog{}, system.SysError{}, system.SysApiToken{}, system.SysLoginLog{}, @@ -64,6 +68,18 @@ func RegisterTables() { commonModel.ExaFileChunk{}, commonModel.ExaFileUploadAndDownload{}, commonModel.ExaAttachmentCategory{}, + + appModel.AppUser{}, + appModel.AppUserProfile{}, + appModel.AppInviteCode{}, + appModel.AppUserInviteRelation{}, + appModel.AppUserAsset{}, + appModel.AppUserActivity{}, + appModel.AppAssetTransaction{}, + appModel.AppLoginLog{}, + appModel.AppWithdrawOrder{}, + appModel.AppRiskTag{}, + appModel.AppUserRiskTag{}, ) if err != nil { global.GVA_LOG.Error("register table failed", zap.Error(err)) diff --git a/server/initialize/router.go b/server/initialize/router.go index 83324ec..66dca36 100644 --- a/server/initialize/router.go +++ b/server/initialize/router.go @@ -43,6 +43,7 @@ func Routers() *gin.Engine { systemRouter := router.RouterGroupApp.System commonRouter := router.RouterGroupApp.Common + appRouter := router.RouterGroupApp.App // 如果想要不使用nginx代理前端网页,可以修改 web/.env.production 下的 // VUE_APP_BASE_API = / // VUE_APP_BASE_PATH = http://localhost @@ -64,9 +65,11 @@ func Routers() *gin.Engine { // 方便统一添加路由组前缀 多服务器上线使用 PublicGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix) - PrivateGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix) + AdminGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix) + AppGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix) - PrivateGroup.Use(middleware.JWTAuth()).Use(middleware.CasbinHandler()) + AdminGroup.Use(middleware.JWTAuth()).Use(middleware.CasbinHandler()) + AppGroup.Use(middleware.AppJWTAuth()).Use(middleware.AppActivity()) { // 健康监测 @@ -75,33 +78,42 @@ func Routers() *gin.Engine { }) } { - systemRouter.InitBaseRouter(PublicGroup) // 注册基础功能路由 不做鉴权 - systemRouter.InitInitRouter(PublicGroup) // 自动初始化相关 + systemRouter.InitBaseRouter(PublicGroup) // 注册基础功能路由 不做鉴权 + systemRouter.InitInitRouter(PublicGroup) // 自动初始化相关 + systemRouter.InitPublicAuthRouter(PublicGroup) // 用户前端注册相关(public) + appRouter.InitPublicAuthRouter(PublicGroup) // 玩家端注册/登录(public) } { - systemRouter.InitApiRouter(PrivateGroup, PublicGroup) // 注册功能api路由 - systemRouter.InitJwtRouter(PrivateGroup) // jwt相关路由 - systemRouter.InitUserRouter(PrivateGroup) // 注册用户路由 - systemRouter.InitMenuRouter(PrivateGroup) // 注册menu路由 - systemRouter.InitSystemRouter(PrivateGroup) // system相关路由 - systemRouter.InitCasbinRouter(PrivateGroup) // 权限相关路由 - systemRouter.InitAuthorityRouter(PrivateGroup) // 注册角色路由 - systemRouter.InitSysDictionaryRouter(PrivateGroup) // 字典管理 - systemRouter.InitSysOperationRecordRouter(PrivateGroup) // 操作记录 - systemRouter.InitSysDictionaryDetailRouter(PrivateGroup) // 字典详情管理 - systemRouter.InitAuthorityBtnRouterRouter(PrivateGroup) // 按钮权限管理 - systemRouter.InitMcpRouter(PrivateGroup) // MCP 管理 - systemRouter.InitSysParamsRouter(PrivateGroup, PublicGroup) // 参数管理 - systemRouter.InitSysErrorRouter(PrivateGroup, PublicGroup) // 错误日志 - systemRouter.InitLoginLogRouter(PrivateGroup) // 登录日志 - systemRouter.InitApiTokenRouter(PrivateGroup) // apiToken签发 - commonRouter.InitFileUploadAndDownloadRouter(PrivateGroup) // 文件上传下载功能路由 - commonRouter.InitAttachmentCategoryRouterRouter(PrivateGroup) // 文件上传下载分类 + systemRouter.InitApiRouter(AdminGroup, PublicGroup) // 注册功能api路由 + systemRouter.InitJwtRouter(AdminGroup) // jwt相关路由 + systemRouter.InitUserRouter(AdminGroup) // 注册用户路由 + systemRouter.InitInviteCodeRouter(AdminGroup) // 邀请码(登录态) + systemRouter.InitMenuRouter(AdminGroup) // 注册menu路由 + systemRouter.InitSystemRouter(AdminGroup) // system相关路由 + systemRouter.InitCasbinRouter(AdminGroup) // 权限相关路由 + systemRouter.InitAuthorityRouter(AdminGroup) // 注册角色路由 + systemRouter.InitSysDictionaryRouter(AdminGroup) // 字典管理 + systemRouter.InitSysOperationRecordRouter(AdminGroup) // 操作记录 + systemRouter.InitSysDictionaryDetailRouter(AdminGroup) // 字典详情管理 + systemRouter.InitAuthorityBtnRouterRouter(AdminGroup) // 按钮权限管理 + systemRouter.InitMcpRouter(AdminGroup) // MCP 管理 + systemRouter.InitSysParamsRouter(AdminGroup, PublicGroup) // 参数管理 + systemRouter.InitSysErrorRouter(AdminGroup, PublicGroup) // 错误日志 + systemRouter.InitLoginLogRouter(AdminGroup) // 登录日志 + systemRouter.InitApiTokenRouter(AdminGroup) // apiToken签发 + commonRouter.InitFileUploadAndDownloadRouter(AdminGroup) // 文件上传下载功能路由 + commonRouter.InitAttachmentCategoryRouterRouter(AdminGroup) // 文件上传下载分类 + } + + // 玩家端登录态路由(暂与 PrivateGroup 共用 JWT,中间件在 jwt-audience todo 中拆分) + { + appRouter.InitAppAuthRouter(AppGroup) + appRouter.InitInviteCodeRouter(AppGroup) } // 注册业务路由 - initBizRouter(PrivateGroup, PublicGroup) + initBizRouter(AdminGroup, PublicGroup) global.GVA_ROUTERS = Router.Routes() diff --git a/server/initialize/router_biz.go b/server/initialize/router_biz.go index 332fb26..27ef4e6 100644 --- a/server/initialize/router_biz.go +++ b/server/initialize/router_biz.go @@ -1,6 +1,7 @@ package initialize import ( + v1 "git.echol.cn/loser/Go-Web-Template/server/api/v1" "git.echol.cn/loser/Go-Web-Template/server/router" "github.com/gin-gonic/gin" ) @@ -16,4 +17,42 @@ func initBizRouter(routers ...*gin.RouterGroup) { holder(publicGroup, privateGroup) + // 管理端扩展接口(需 JWT + Casbin,与 PrivateGroup 一致) + privateGroup.GET("/admin/dashboard/stats", v1.ApiGroupApp.AdminApiGroup.DashboardApi.GetDashboardStats) + privateGroup.GET("/admin/app-users", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.GetAppUsersList) + privateGroup.GET("/admin/app-users/:id", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.GetAppUserById) + privateGroup.GET("/admin/app-users/:id/overview", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.GetAppUserOverview) + privateGroup.POST("/admin/app-users", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.CreateAppUser) + privateGroup.PATCH("/admin/app-users/:id", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.UpdateAppUser) + privateGroup.POST("/admin/app-users/:id/reset-password", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.ResetAppUserPassword) + privateGroup.POST("/admin/app-users/:id/toggle-enable", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.ToggleAppUserEnable) + privateGroup.DELETE("/admin/app-users/:id", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.DeleteAppUser) + privateGroup.POST("/admin/app-users/:id/recharge", v1.ApiGroupApp.AdminApiGroup.AppUsersApi.RechargeAppUser) + + privateGroup.GET("/admin/app-login-logs", v1.ApiGroupApp.AdminApiGroup.AppLoginLogsApi.GetAppLoginLogsList) + + privateGroup.GET("/admin/app-asset-transactions", v1.ApiGroupApp.AdminApiGroup.AppAssetTransactionsApi.GetAppAssetTransactionsList) + privateGroup.GET("/admin/app-asset-transactions/:id", v1.ApiGroupApp.AdminApiGroup.AppAssetTransactionsApi.GetAppAssetTransactionById) + + privateGroup.GET("/admin/app-withdraw-orders", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.GetAppWithdrawOrdersList) + privateGroup.GET("/admin/app-withdraw-orders/:id", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.GetAppWithdrawOrderById) + privateGroup.POST("/admin/app-withdraw-orders", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.CreateAppWithdrawOrder) + privateGroup.POST("/admin/app-withdraw-orders/:id/approve", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.ApproveAppWithdrawOrder) + privateGroup.POST("/admin/app-withdraw-orders/:id/reject", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.RejectAppWithdrawOrder) + privateGroup.POST("/admin/app-withdraw-orders/:id/mark-paid", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.MarkPaidAppWithdrawOrder) + privateGroup.POST("/admin/app-withdraw-orders/:id/mark-failed", v1.ApiGroupApp.AdminApiGroup.AppWithdrawOrdersApi.MarkFailedAppWithdrawOrder) + + privateGroup.GET("/admin/app-risk-tags", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.GetAppRiskTagsList) + privateGroup.POST("/admin/app-risk-tags", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.CreateAppRiskTag) + privateGroup.PUT("/admin/app-risk-tags/:id", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.UpdateAppRiskTag) + privateGroup.DELETE("/admin/app-risk-tags/:id", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.DeleteAppRiskTag) + privateGroup.GET("/admin/app-users/:id/risk-tags", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.GetAppUserRiskTags) + privateGroup.POST("/admin/app-users/:id/risk-tags", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.AddAppUserRiskTag) + privateGroup.DELETE("/admin/app-user-risk-tags/:id", v1.ApiGroupApp.AdminApiGroup.AppRiskTagsApi.RemoveAppUserRiskTag) + + privateGroup.GET("/admin/app-invite-codes", v1.ApiGroupApp.AdminApiGroup.AppInviteCodesApi.GetAppInviteCodesList) + privateGroup.GET("/admin/app-invite-codes/:id", v1.ApiGroupApp.AdminApiGroup.AppInviteCodesApi.GetAppInviteCodeById) + privateGroup.POST("/admin/app-invite-codes/:id/revoke", v1.ApiGroupApp.AdminApiGroup.AppInviteCodesApi.RevokeAppInviteCode) + privateGroup.POST("/admin/app-invite-codes/issue", v1.ApiGroupApp.AdminApiGroup.AppInviteCodesApi.IssueAppInviteCodeForUser) + privateGroup.POST("/admin/app-invite-codes/clear-unused", v1.ApiGroupApp.AdminApiGroup.AppInviteCodesApi.ClearUnusedInviteCodes) } diff --git a/server/middleware/app_activity.go b/server/middleware/app_activity.go new file mode 100644 index 0000000..195d764 --- /dev/null +++ b/server/middleware/app_activity.go @@ -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,避免未来扩展时频繁改动(不影响逻辑) + } +} diff --git a/server/middleware/app_jwt.go b/server/middleware/app_jwt.go new file mode 100644 index 0000000..de04f01 --- /dev/null +++ b/server/middleware/app_jwt.go @@ -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)) + } + } +} diff --git a/server/middleware/cors.go b/server/middleware/cors.go index a52bc1d..01b0c0e 100644 --- a/server/middleware/cors.go +++ b/server/middleware/cors.go @@ -18,11 +18,11 @@ func Cors() gin.HandlerFunc { c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type, New-Token, New-Expires-At") c.Header("Access-Control-Allow-Credentials", "true") - // 放行所有OPTIONS方法 + // 放行所有OPTIONS方法(预检请求不应继续进入路由,否则易落到 404) if method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) + return } - // 处理请求 c.Next() } } @@ -50,14 +50,14 @@ func CorsByRules() gin.HandlerFunc { // 严格白名单模式且未通过检查,直接拒绝处理请求 if whitelist == nil && global.GVA_CONFIG.Cors.Mode == "strict-whitelist" && !(c.Request.Method == "GET" && c.Request.URL.Path == "/health") { c.AbortWithStatus(http.StatusForbidden) - } else { - // 非严格白名单模式,无论是否通过检查均放行所有 OPTIONS 方法 - if c.Request.Method == http.MethodOptions { - c.AbortWithStatus(http.StatusNoContent) - } + return + } + // OPTIONS 预检:直接结束,避免继续匹配路由导致 404 + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return } - // 处理请求 c.Next() } } diff --git a/server/middleware/jwt.go b/server/middleware/jwt.go index 045621a..42f7ca3 100644 --- a/server/middleware/jwt.go +++ b/server/middleware/jwt.go @@ -44,6 +44,14 @@ func JWTAuth() gin.HandlerFunc { return } + // 后台接口仅接受后台受众 token + if len(claims.Audience) == 0 || claims.Audience[0] != utils.JWTAudienceAdmin { + response.NoAuth("令牌无效", c) + utils.ClearToken(c) + c.Abort() + return + } + // 已登录用户被管理员禁用 需要使该用户的jwt失效 此处比较消耗性能 如果需要 请自行打开 // 用户被删除的逻辑 需要优化 此处比较消耗性能 如果需要 请自行打开 diff --git a/server/model/admin/dashboard_stats.go b/server/model/admin/dashboard_stats.go new file mode 100644 index 0000000..965e2c0 --- /dev/null +++ b/server/model/admin/dashboard_stats.go @@ -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"` +} diff --git a/server/model/app/app_asset_transaction.go b/server/model/app/app_asset_transaction.go new file mode 100644 index 0000000..62f3117 --- /dev/null +++ b/server/model/app/app_asset_transaction.go @@ -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" } diff --git a/server/model/app/app_invite_code.go b/server/model/app/app_invite_code.go new file mode 100644 index 0000000..e21682c --- /dev/null +++ b/server/model/app/app_invite_code.go @@ -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" +} diff --git a/server/model/app/app_login_log.go b/server/model/app/app_login_log.go new file mode 100644 index 0000000..36a0ab8 --- /dev/null +++ b/server/model/app/app_login_log.go @@ -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" } diff --git a/server/model/app/app_risk_tag.go b/server/model/app/app_risk_tag.go new file mode 100644 index 0000000..9ec36a2 --- /dev/null +++ b/server/model/app/app_risk_tag.go @@ -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" } + diff --git a/server/model/app/app_user.go b/server/model/app/app_user.go new file mode 100644 index 0000000..b4faabe --- /dev/null +++ b/server/model/app/app_user.go @@ -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" +} diff --git a/server/model/app/app_user_activity.go b/server/model/app/app_user_activity.go new file mode 100644 index 0000000..c59974c --- /dev/null +++ b/server/model/app/app_user_activity.go @@ -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" } diff --git a/server/model/app/app_user_asset.go b/server/model/app/app_user_asset.go new file mode 100644 index 0000000..3ecedff --- /dev/null +++ b/server/model/app/app_user_asset.go @@ -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" } diff --git a/server/model/app/app_user_invite_relation.go b/server/model/app/app_user_invite_relation.go new file mode 100644 index 0000000..91cf324 --- /dev/null +++ b/server/model/app/app_user_invite_relation.go @@ -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" +} diff --git a/server/model/app/app_user_profile.go b/server/model/app/app_user_profile.go new file mode 100644 index 0000000..4d4ccb0 --- /dev/null +++ b/server/model/app/app_user_profile.go @@ -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" +} diff --git a/server/model/app/app_user_risk_tag.go b/server/model/app/app_user_risk_tag.go new file mode 100644 index 0000000..37b3ced --- /dev/null +++ b/server/model/app/app_user_risk_tag.go @@ -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" } + diff --git a/server/model/app/app_withdraw_order.go b/server/model/app/app_withdraw_order.go new file mode 100644 index 0000000..4456ea0 --- /dev/null +++ b/server/model/app/app_withdraw_order.go @@ -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" } + diff --git a/server/model/app/request/login.go b/server/model/app/request/login.go new file mode 100644 index 0000000..1b84574 --- /dev/null +++ b/server/model/app/request/login.go @@ -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"` +} diff --git a/server/model/app/request/public_register.go b/server/model/app/request/public_register.go new file mode 100644 index 0000000..656fc27 --- /dev/null +++ b/server/model/app/request/public_register.go @@ -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"` +} diff --git a/server/model/app/response/register_status.go b/server/model/app/response/register_status.go new file mode 100644 index 0000000..bf2b50c --- /dev/null +++ b/server/model/app/response/register_status.go @@ -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"` +} diff --git a/server/model/system/invite_code.go b/server/model/system/invite_code.go new file mode 100644 index 0000000..11ed01a --- /dev/null +++ b/server/model/system/invite_code.go @@ -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" +} diff --git a/server/model/system/request/public_register.go b/server/model/system/request/public_register.go new file mode 100644 index 0000000..34dfc64 --- /dev/null +++ b/server/model/system/request/public_register.go @@ -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"` +} diff --git a/server/model/system/request/sys_user.go b/server/model/system/request/sys_user.go index e7d9207..7621b56 100644 --- a/server/model/system/request/sys_user.go +++ b/server/model/system/request/sys_user.go @@ -50,13 +50,13 @@ type SetUserAuthorities struct { } type ChangeUserInfo struct { - ID uint `gorm:"primarykey"` // 主键ID - NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称 - Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号 - AuthorityIds []uint `json:"authorityIds" gorm:"-"` // 角色ID - Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱 - HeaderImg string `json:"headerImg" gorm:"default:https://qmplusimg.henrongyi.top/gva_header.jpg;comment:用户头像"` // 用户头像 - Enable int `json:"enable" gorm:"comment:冻结用户"` //冻结用户 + ID uint `gorm:"primarykey"` // 主键ID + NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称 + Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号 + AuthorityIds []uint `json:"authorityIds" gorm:"-"` // 角色ID + Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱 + HeaderImg string `json:"headerImg" gorm:"default:https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y;comment:用户头像"` // 用户头像 + Enable int `json:"enable" gorm:"comment:冻结用户"` //冻结用户 Authorities []system.SysAuthority `json:"-" gorm:"many2many:sys_user_authority;"` } diff --git a/server/model/system/response/register_status.go b/server/model/system/response/register_status.go new file mode 100644 index 0000000..bf2b50c --- /dev/null +++ b/server/model/system/response/register_status.go @@ -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"` +} diff --git a/server/model/system/sys_param_change_log.go b/server/model/system/sys_param_change_log.go new file mode 100644 index 0000000..e7ff633 --- /dev/null +++ b/server/model/system/sys_param_change_log.go @@ -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" } diff --git a/server/model/system/sys_user.go b/server/model/system/sys_user.go index 3253b4b..2df0615 100644 --- a/server/model/system/sys_user.go +++ b/server/model/system/sys_user.go @@ -19,18 +19,19 @@ var _ Login = new(SysUser) type SysUser struct { global.GVA_MODEL - UUID uuid.UUID `json:"uuid" gorm:"index;comment:用户UUID"` // 用户UUID - Username string `json:"userName" gorm:"index;comment:用户登录名"` // 用户登录名 - Password string `json:"-" gorm:"comment:用户登录密码"` // 用户登录密码 - NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称 - HeaderImg string `json:"headerImg" gorm:"default:https://qmplusimg.henrongyi.top/gva_header.jpg;comment:用户头像"` // 用户头像 - AuthorityId uint `json:"authorityId" gorm:"default:888;comment:用户角色ID"` // 用户角色ID - Authority SysAuthority `json:"authority" gorm:"foreignKey:AuthorityId;references:AuthorityId;comment:用户角色"` // 用户角色 - Authorities []SysAuthority `json:"authorities" gorm:"many2many:sys_user_authority;"` // 多用户角色 - Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号 - Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱 - Enable int `json:"enable" gorm:"default:1;comment:用户是否被冻结 1正常 2冻结"` //用户是否被冻结 1正常 2冻结 - OriginSetting common.JSONMap `json:"originSetting" form:"originSetting" gorm:"type:text;default:null;column:origin_setting;comment:配置;"` //配置 + UUID uuid.UUID `json:"uuid" gorm:"index;comment:用户UUID"` // 用户UUID + Username string `json:"userName" gorm:"index;comment:用户登录名"` // 用户登录名 + Password string `json:"-" gorm:"comment:用户登录密码"` // 用户登录密码 + NickName string `json:"nickName" gorm:"default:系统用户;comment:用户昵称"` // 用户昵称 + HeaderImg string `json:"headerImg" gorm:"default:https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y;comment:用户头像"` // 用户头像 + WelcomePhrase string `json:"welcomePhrase" gorm:"type:varchar(255);default:'';comment:欢迎词"` // 欢迎词 + AuthorityId uint `json:"authorityId" gorm:"default:888;comment:用户角色ID"` // 用户角色ID + Authority SysAuthority `json:"authority" gorm:"foreignKey:AuthorityId;references:AuthorityId;comment:用户角色"` // 用户角色 + Authorities []SysAuthority `json:"authorities" gorm:"many2many:sys_user_authority;"` // 多用户角色 + Phone string `json:"phone" gorm:"comment:用户手机号"` // 用户手机号 + Email string `json:"email" gorm:"comment:用户邮箱"` // 用户邮箱 + Enable int `json:"enable" gorm:"default:1;comment:用户是否被冻结 1正常 2冻结"` //用户是否被冻结 1正常 2冻结 + OriginSetting common.JSONMap `json:"originSetting" form:"originSetting" gorm:"type:text;default:null;column:origin_setting;comment:配置;"` //配置 } func (SysUser) TableName() string { diff --git a/server/model/system/user_invite_relation.go b/server/model/system/user_invite_relation.go new file mode 100644 index 0000000..25fa958 --- /dev/null +++ b/server/model/system/user_invite_relation.go @@ -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" +} diff --git a/server/router/app/app_auth.go b/server/router/app/app_auth.go new file mode 100644 index 0000000..cd105bb --- /dev/null +++ b/server/router/app/app_auth.go @@ -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) + } +} diff --git a/server/router/app/enter.go b/server/router/app/enter.go new file mode 100644 index 0000000..f0c67ab --- /dev/null +++ b/server/router/app/enter.go @@ -0,0 +1,7 @@ +package app + +type RouterGroup struct { + PublicAuthRouter + AppAuthRouter + InviteCodeRouter +} diff --git a/server/router/app/invite_code.go b/server/router/app/invite_code.go new file mode 100644 index 0000000..06f94c3 --- /dev/null +++ b/server/router/app/invite_code.go @@ -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) + } +} diff --git a/server/router/app/public_auth.go b/server/router/app/public_auth.go new file mode 100644 index 0000000..c25d584 --- /dev/null +++ b/server/router/app/public_auth.go @@ -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) + } +} diff --git a/server/router/enter.go b/server/router/enter.go index 2fdafb1..68c6fa7 100644 --- a/server/router/enter.go +++ b/server/router/enter.go @@ -1,6 +1,7 @@ package router import ( + "git.echol.cn/loser/Go-Web-Template/server/router/app" "git.echol.cn/loser/Go-Web-Template/server/router/common" "git.echol.cn/loser/Go-Web-Template/server/router/system" ) @@ -10,4 +11,5 @@ var RouterGroupApp = new(RouterGroup) type RouterGroup struct { System system.RouterGroup Common common.RouterGroup + App app.RouterGroup } diff --git a/server/router/system/enter.go b/server/router/system/enter.go index 05798f6..e478c9d 100644 --- a/server/router/system/enter.go +++ b/server/router/system/enter.go @@ -7,9 +7,11 @@ type RouterGroup struct { JwtRouter SysRouter BaseRouter + PublicAuthRouter InitRouter MenuRouter UserRouter + InviteCodeRouter CasbinRouter AuthorityRouter DictionaryRouter @@ -39,4 +41,6 @@ var ( dictionaryDetailApi = api.ApiGroupApp.SystemApiGroup.DictionaryDetailApi mcpApi = api.ApiGroupApp.SystemApiGroup.McpApi sysErrorApi = api.ApiGroupApp.SystemApiGroup.SysErrorApi + publicAuthApi = api.ApiGroupApp.SystemApiGroup.PublicAuthApi + inviteCodeApi = api.ApiGroupApp.SystemApiGroup.InviteCodeApi ) diff --git a/server/router/system/invite_code.go b/server/router/system/invite_code.go new file mode 100644 index 0000000..ce88a51 --- /dev/null +++ b/server/router/system/invite_code.go @@ -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) + } +} diff --git a/server/router/system/public_auth.go b/server/router/system/public_auth.go new file mode 100644 index 0000000..435b4db --- /dev/null +++ b/server/router/system/public_auth.go @@ -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) + } +} diff --git a/server/router/system/sys_params.go b/server/router/system/sys_params.go index b60830d..17e4d2d 100644 --- a/server/router/system/sys_params.go +++ b/server/router/system/sys_params.go @@ -18,8 +18,9 @@ func (s *SysParamsRouter) InitSysParamsRouter(Router *gin.RouterGroup, PublicRou sysParamsRouter.PUT("updateSysParams", sysParamsApi.UpdateSysParams) // 更新参数 } { - sysParamsRouterWithoutRecord.GET("findSysParams", sysParamsApi.FindSysParams) // 根据ID获取参数 - sysParamsRouterWithoutRecord.GET("getSysParamsList", sysParamsApi.GetSysParamsList) // 获取参数列表 - sysParamsRouterWithoutRecord.GET("getSysParam", sysParamsApi.GetSysParam) // 根据Key获取参数 + sysParamsRouterWithoutRecord.GET("findSysParams", sysParamsApi.FindSysParams) // 根据ID获取参数 + sysParamsRouterWithoutRecord.GET("getSysParamsList", sysParamsApi.GetSysParamsList) // 获取参数列表 + sysParamsRouterWithoutRecord.GET("getSysParam", sysParamsApi.GetSysParam) // 根据Key获取参数 + sysParamsRouterWithoutRecord.GET("getChangeLogList", sysParamsApi.GetSysParamChangeLogList) // 获取参数变更审计 } } diff --git a/server/service/app/activity.go b/server/service/app/activity.go new file mode 100644 index 0000000..56a8cbd --- /dev/null +++ b/server/service/app/activity.go @@ -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 +} diff --git a/server/service/app/auth.go b/server/service/app/auth.go new file mode 100644 index 0000000..aa64f93 --- /dev/null +++ b/server/service/app/auth.go @@ -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 +} diff --git a/server/service/app/enter.go b/server/service/app/enter.go new file mode 100644 index 0000000..f0d91f0 --- /dev/null +++ b/server/service/app/enter.go @@ -0,0 +1,7 @@ +package app + +type ServiceGroup struct { + AppAuthService + AppRegisterService + AppInviteCodeService +} diff --git a/server/service/app/invite_code.go b/server/service/app/invite_code.go new file mode 100644 index 0000000..9788da3 --- /dev/null +++ b/server/service/app/invite_code.go @@ -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 +} diff --git a/server/service/app/register.go b/server/service/app/register.go new file mode 100644 index 0000000..d43611a --- /dev/null +++ b/server/service/app/register.go @@ -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 + }) +} diff --git a/server/service/app/register_config.go b/server/service/app/register_config.go new file mode 100644 index 0000000..2a2a4d6 --- /dev/null +++ b/server/service/app/register_config.go @@ -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() } diff --git a/server/service/enter.go b/server/service/enter.go index 2432904..8fa614b 100644 --- a/server/service/enter.go +++ b/server/service/enter.go @@ -1,6 +1,7 @@ package service import ( + "git.echol.cn/loser/Go-Web-Template/server/service/app" "git.echol.cn/loser/Go-Web-Template/server/service/common" "git.echol.cn/loser/Go-Web-Template/server/service/system" ) @@ -10,4 +11,5 @@ var ServiceGroupApp = new(ServiceGroup) type ServiceGroup struct { SystemServiceGroup system.ServiceGroup CommonServiceGroup common.ServiceGroup + AppServiceGroup app.ServiceGroup } diff --git a/server/service/system/app_online_config.go b/server/service/system/app_online_config.go new file mode 100644 index 0000000..af13dbf --- /dev/null +++ b/server/service/system/app_online_config.go @@ -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 +} diff --git a/server/service/system/enter.go b/server/service/system/enter.go index 240d22e..b0b76e2 100644 --- a/server/service/system/enter.go +++ b/server/service/system/enter.go @@ -5,6 +5,8 @@ type ServiceGroup struct { ApiService MenuService UserService + InviteCodeService + PublicRegisterService CasbinService InitDBService BaseMenuService diff --git a/server/service/system/invite_code.go b/server/service/system/invite_code.go new file mode 100644 index 0000000..dac4af7 --- /dev/null +++ b/server/service/system/invite_code.go @@ -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 +} diff --git a/server/service/system/public_register.go b/server/service/system/public_register.go new file mode 100644 index 0000000..c04f352 --- /dev/null +++ b/server/service/system/public_register.go @@ -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 + }) +} diff --git a/server/service/system/register_config.go b/server/service/system/register_config.go new file mode 100644 index 0000000..6efc3dc --- /dev/null +++ b/server/service/system/register_config.go @@ -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() +} diff --git a/server/service/system/sys_api.go b/server/service/system/sys_api.go index d7023b0..12015c1 100644 --- a/server/service/system/sys_api.go +++ b/server/service/system/sys_api.go @@ -102,10 +102,20 @@ func (apiService *ApiService) SyncApi() (newApis, deleteApis, ignoreApis []syste } } if !flag { + // prefill ApiGroup/Description from default meta map if available + apiGroup := "" + desc := "" + if global.GVA_API_DEFAULT_META != nil { + k := strings.ToUpper(strings.TrimSpace(cacheApis[i].Method)) + " " + strings.TrimSpace(cacheApis[i].Path) + if m, ok := global.GVA_API_DEFAULT_META[k]; ok { + apiGroup = m.ApiGroup + desc = m.Description + } + } newApis = append(newApis, system.SysApi{ Path: cacheApis[i].Path, - Description: "", - ApiGroup: "", + Description: desc, + ApiGroup: apiGroup, Method: cacheApis[i].Method, }) } diff --git a/server/source/common/file_upload_download.go b/server/source/common/file_upload_download.go index ac4ef78..74e4d97 100644 --- a/server/source/common/file_upload_download.go +++ b/server/source/common/file_upload_download.go @@ -2,6 +2,7 @@ package common import ( "context" + commonModel "git.echol.cn/loser/Go-Web-Template/server/model/common" "git.echol.cn/loser/Go-Web-Template/server/service/system" "github.com/pkg/errors" diff --git a/server/source/system/api.go b/server/source/system/api.go index f8cd3e4..3c0b986 100644 --- a/server/source/system/api.go +++ b/server/source/system/api.go @@ -2,7 +2,9 @@ package system import ( "context" + "strings" + "git.echol.cn/loser/Go-Web-Template/server/global" sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system" "git.echol.cn/loser/Go-Web-Template/server/service/system" "github.com/pkg/errors" @@ -13,9 +15,81 @@ type initApi struct{} const initOrderApi = system.InitOrderSystem + 1 +func registerApiDefaultMeta(entities []sysModel.SysApi) { + if global.GVA_API_DEFAULT_META == nil { + global.GVA_API_DEFAULT_META = make(map[string]struct { + ApiGroup string + Description string + }, len(entities)) + } + for _, e := range entities { + key := strings.ToUpper(strings.TrimSpace(e.Method)) + " " + strings.TrimSpace(e.Path) + if _, ok := global.GVA_API_DEFAULT_META[key]; !ok { + global.GVA_API_DEFAULT_META[key] = struct { + ApiGroup string + Description string + }{ApiGroup: e.ApiGroup, Description: e.Description} + } + } +} + // auto run func init() { system.RegisterInit(initOrderApi, &initApi{}) + // ensure default meta available even when db seed is skipped + if global.GVA_API_DEFAULT_META == nil { + global.GVA_API_DEFAULT_META = make(map[string]struct { + ApiGroup string + Description string + }) + } + // 关键:即使跳过 SysApi 灌库,也要能在“同步路由”时预填分组与简介 + // 这里只注册“我们新增/扩展过的接口”以及玩家端相关接口,避免 UI 再次手填。 + registerApiDefaultMeta([]sysModel.SysApi{ + {ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getChangeLogList", Description: "获取参数变更审计"}, + + {ApiGroup: "玩家管理", Method: "GET", Path: "/admin/app-users/:id", Description: "获取玩家详情"}, + {ApiGroup: "玩家管理", Method: "GET", Path: "/admin/app-users/:id/overview", Description: "获取玩家聚合概览"}, + {ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/reset-password", Description: "重置玩家密码"}, + {ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/toggle-enable", Description: "启用/停用玩家"}, + {ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/recharge", Description: "后台手动充值(账户余额/游戏币)"}, + {ApiGroup: "玩家管理", Method: "DELETE", Path: "/admin/app-users/:id", Description: "软删除玩家"}, + {ApiGroup: "玩家管理", Method: "PATCH", Path: "/admin/app-users/:id", Description: "编辑玩家(仅启用状态/欢迎词)"}, + + {ApiGroup: "玩家资产流水", Method: "GET", Path: "/admin/app-asset-transactions", Description: "获取玩家资产流水列表"}, + {ApiGroup: "玩家资产流水", Method: "GET", Path: "/admin/app-asset-transactions/:id", Description: "获取玩家资产流水详情"}, + + {ApiGroup: "提现管理", Method: "GET", Path: "/admin/app-withdraw-orders", Description: "获取提现订单列表"}, + {ApiGroup: "提现管理", Method: "GET", Path: "/admin/app-withdraw-orders/:id", Description: "获取提现订单详情"}, + {ApiGroup: "提现管理", Method: "POST", Path: "/admin/app-withdraw-orders", Description: "后台创建提现订单(冻结账户余额)"}, + {ApiGroup: "提现管理", Method: "POST", Path: "/admin/app-withdraw-orders/:id/approve", Description: "审核通过提现订单"}, + {ApiGroup: "提现管理", Method: "POST", Path: "/admin/app-withdraw-orders/:id/reject", Description: "审核拒绝提现订单(解冻)"}, + {ApiGroup: "提现管理", Method: "POST", Path: "/admin/app-withdraw-orders/:id/mark-paid", Description: "确认已打款(扣减余额并解冻)"}, + {ApiGroup: "提现管理", Method: "POST", Path: "/admin/app-withdraw-orders/:id/mark-failed", Description: "标记打款失败(解冻)"}, + + {ApiGroup: "风控标签", Method: "GET", Path: "/admin/app-risk-tags", Description: "获取风险标签列表"}, + {ApiGroup: "风控标签", Method: "POST", Path: "/admin/app-risk-tags", Description: "创建风险标签"}, + {ApiGroup: "风控标签", Method: "PUT", Path: "/admin/app-risk-tags/:id", Description: "更新风险标签"}, + {ApiGroup: "风控标签", Method: "DELETE", Path: "/admin/app-risk-tags/:id", Description: "删除风险标签"}, + {ApiGroup: "风控标签", Method: "GET", Path: "/admin/app-users/:id/risk-tags", Description: "获取玩家风险标签"}, + {ApiGroup: "风控标签", Method: "POST", Path: "/admin/app-users/:id/risk-tags", Description: "给玩家打标"}, + {ApiGroup: "风控标签", Method: "DELETE", Path: "/admin/app-user-risk-tags/:id", Description: "移除玩家标签"}, + + {ApiGroup: "玩家登录日志", Method: "GET", Path: "/admin/app-login-logs", Description: "获取玩家登录日志列表"}, + + {ApiGroup: "邀请码管理", Method: "GET", Path: "/admin/app-invite-codes/:id", Description: "获取玩家邀请码详情"}, + {ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/:id/revoke", Description: "作废玩家邀请码"}, + {ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/issue", Description: "后台代用户生成邀请码"}, + {ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/clear-unused", Description: "强制清空用户未使用邀请码"}, + + {ApiGroup: "玩家端-个人中心", Method: "GET", Path: "/app/me", Description: "获取当前玩家信息"}, + {ApiGroup: "玩家端-邀请码", Method: "GET", Path: "/app/invite-code/current", Description: "获取当前未使用邀请码"}, + {ApiGroup: "玩家端-邀请码", Method: "POST", Path: "/app/invite-code/generate", Description: "生成邀请码"}, + + {ApiGroup: "玩家端-注册登录", Method: "GET", Path: "/public/app/register/status", Description: "获取注册状态"}, + {ApiGroup: "玩家端-注册登录", Method: "POST", Path: "/public/app/register", Description: "玩家注册"}, + {ApiGroup: "玩家端-注册登录", Method: "POST", Path: "/public/app/login", Description: "玩家登录"}, + }) } func (i *initApi) InitializerName() string { @@ -67,17 +141,31 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) { {ApiGroup: "系统用户", Method: "POST", Path: "/user/resetPassword", Description: "重置用户密码"}, {ApiGroup: "系统用户", Method: "PUT", Path: "/user/setSelfSetting", Description: "用户界面配置"}, - {ApiGroup: "api", Method: "POST", Path: "/api/createApi", Description: "创建api"}, - {ApiGroup: "api", Method: "POST", Path: "/api/deleteApi", Description: "删除Api"}, - {ApiGroup: "api", Method: "POST", Path: "/api/updateApi", Description: "更新Api"}, - {ApiGroup: "api", Method: "POST", Path: "/api/getApiList", Description: "获取api列表"}, - {ApiGroup: "api", Method: "POST", Path: "/api/getAllApis", Description: "获取所有api"}, - {ApiGroup: "api", Method: "POST", Path: "/api/getApiById", Description: "获取api详细信息"}, - {ApiGroup: "api", Method: "DELETE", Path: "/api/deleteApisByIds", Description: "批量删除api"}, - {ApiGroup: "api", Method: "GET", Path: "/api/syncApi", Description: "获取待同步API"}, - {ApiGroup: "api", Method: "GET", Path: "/api/getApiGroups", Description: "获取路由组"}, - {ApiGroup: "api", Method: "POST", Path: "/api/enterSyncApi", Description: "确认同步API"}, - {ApiGroup: "api", Method: "POST", Path: "/api/ignoreApi", Description: "忽略API"}, + {ApiGroup: "API管理", Method: "POST", Path: "/api/createApi", Description: "创建api"}, + {ApiGroup: "API管理", Method: "POST", Path: "/api/deleteApi", Description: "删除Api"}, + {ApiGroup: "API管理", Method: "POST", Path: "/api/updateApi", Description: "更新Api"}, + {ApiGroup: "API管理", Method: "POST", Path: "/api/getApiList", Description: "获取api列表"}, + {ApiGroup: "API管理", Method: "POST", Path: "/api/getAllApis", Description: "获取所有api"}, + {ApiGroup: "API管理", Method: "POST", Path: "/api/getApiById", Description: "获取api详细信息"}, + {ApiGroup: "API管理", Method: "DELETE", Path: "/api/deleteApisByIds", Description: "批量删除api"}, + {ApiGroup: "API管理", Method: "GET", Path: "/api/syncApi", Description: "获取待同步API"}, + {ApiGroup: "API管理", Method: "GET", Path: "/api/getApiGroups", Description: "获取路由组"}, + {ApiGroup: "API管理", Method: "POST", Path: "/api/enterSyncApi", Description: "确认同步API"}, + {ApiGroup: "API管理", Method: "POST", Path: "/api/ignoreApi", Description: "忽略API"}, + + // 玩家端(登录态) + {ApiGroup: "玩家端-个人中心", Method: "GET", Path: "/app/me", Description: "获取当前玩家信息"}, + {ApiGroup: "玩家端-邀请码", Method: "GET", Path: "/app/invite-code/current", Description: "获取当前未使用邀请码"}, + {ApiGroup: "玩家端-邀请码", Method: "POST", Path: "/app/invite-code/generate", Description: "生成邀请码"}, + + // 玩家端(公开:注册/登录) + {ApiGroup: "玩家端-注册登录", Method: "GET", Path: "/public/app/register/status", Description: "获取注册状态"}, + {ApiGroup: "玩家端-注册登录", Method: "POST", Path: "/public/app/register", Description: "玩家注册"}, + {ApiGroup: "玩家端-注册登录", Method: "POST", Path: "/public/app/login", Description: "玩家登录"}, + + // 用户前端(system/public) + {ApiGroup: "用户前端-注册", Method: "GET", Path: "/public/auth/register/status", Description: "获取注册状态"}, + {ApiGroup: "用户前端-注册", Method: "POST", Path: "/public/auth/register", Description: "账号注册"}, {ApiGroup: "角色", Method: "POST", Path: "/authority/copyAuthority", Description: "拷贝角色"}, {ApiGroup: "角色", Method: "POST", Path: "/authority/createAuthority", Description: "创建角色"}, @@ -115,6 +203,26 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) { {ApiGroup: "系统服务", Method: "POST", Path: "/system/getServerInfo", Description: "获取服务器信息"}, {ApiGroup: "系统服务", Method: "POST", Path: "/system/getSystemConfig", Description: "获取配置文件内容"}, {ApiGroup: "系统服务", Method: "POST", Path: "/system/setSystemConfig", Description: "设置配置文件内容"}, + {ApiGroup: "运营大屏", Method: "GET", Path: "/admin/dashboard/stats", Description: "仪表盘统计数据"}, + {ApiGroup: "邀请码", Method: "POST", Path: "/user/invite-code/generate", Description: "生成邀请码"}, + {ApiGroup: "邀请码", Method: "GET", Path: "/user/invite-code/current", Description: "获取当前未使用邀请码"}, + {ApiGroup: "玩家管理", Method: "GET", Path: "/admin/app-users", Description: "获取玩家列表"}, + {ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users", Description: "手动新增玩家"}, + {ApiGroup: "玩家管理", Method: "GET", Path: "/admin/app-users/:id", Description: "获取玩家详情"}, + {ApiGroup: "玩家管理", Method: "GET", Path: "/admin/app-users/:id/overview", Description: "获取玩家聚合概览"}, + {ApiGroup: "玩家管理", Method: "PATCH", Path: "/admin/app-users/:id", Description: "编辑玩家(仅启用状态/欢迎词)"}, + {ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/reset-password", Description: "重置玩家密码"}, + {ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/toggle-enable", Description: "启用/停用玩家"}, + {ApiGroup: "玩家管理", Method: "DELETE", Path: "/admin/app-users/:id", Description: "软删除玩家"}, + {ApiGroup: "玩家管理", Method: "POST", Path: "/admin/app-users/:id/recharge", Description: "后台手动充值(账户余额/游戏币)"}, + {ApiGroup: "玩家资产流水", Method: "GET", Path: "/admin/app-asset-transactions", Description: "获取玩家资产流水列表"}, + {ApiGroup: "玩家资产流水", Method: "GET", Path: "/admin/app-asset-transactions/:id", Description: "获取玩家资产流水详情"}, + {ApiGroup: "玩家登录日志", Method: "GET", Path: "/admin/app-login-logs", Description: "获取玩家登录日志列表"}, + {ApiGroup: "邀请码管理", Method: "GET", Path: "/admin/app-invite-codes", Description: "获取玩家邀请码列表"}, + {ApiGroup: "邀请码管理", Method: "GET", Path: "/admin/app-invite-codes/:id", Description: "获取玩家邀请码详情"}, + {ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/:id/revoke", Description: "作废玩家邀请码"}, + {ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/issue", Description: "后台代用户生成邀请码"}, + {ApiGroup: "邀请码管理", Method: "POST", Path: "/admin/app-invite-codes/clear-unused", Description: "强制清空用户未使用邀请码"}, {ApiGroup: "MCP管理", Method: "GET", Path: "/mcp/status", Description: "获取 MCP 独立服务状态"}, {ApiGroup: "MCP管理", Method: "POST", Path: "/mcp/start", Description: "启动 MCP 独立服务"}, @@ -174,10 +282,27 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) { {ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/findSysParams", Description: "根据ID获取参数"}, {ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getSysParamsList", Description: "获取参数列表"}, {ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getSysParam", Description: "获取参数列表"}, + {ApiGroup: "参数管理", Method: "GET", Path: "/sysParams/getChangeLogList", Description: "获取参数变更审计"}, {ApiGroup: "媒体库分类", Method: "GET", Path: "/attachmentCategory/getCategoryList", Description: "分类列表"}, {ApiGroup: "媒体库分类", Method: "POST", Path: "/attachmentCategory/addCategory", Description: "添加/编辑分类"}, {ApiGroup: "媒体库分类", Method: "POST", Path: "/attachmentCategory/deleteCategory", Description: "删除分类"}, } + // register default meta for SyncApi prefill (best-effort) + if global.GVA_API_DEFAULT_META == nil { + global.GVA_API_DEFAULT_META = make(map[string]struct { + ApiGroup string + Description string + }, len(entities)) + } + for _, e := range entities { + key := strings.ToUpper(strings.TrimSpace(e.Method)) + " " + strings.TrimSpace(e.Path) + if _, ok := global.GVA_API_DEFAULT_META[key]; !ok { + global.GVA_API_DEFAULT_META[key] = struct { + ApiGroup string + Description string + }{ApiGroup: e.ApiGroup, Description: e.Description} + } + } if err := db.Create(&entities).Error; err != nil { return ctx, errors.Wrap(err, sysModel.SysApi{}.TableName()+"表数据初始化失败!") } diff --git a/server/source/system/casbin.go b/server/source/system/casbin.go index 2c7315f..a1d844a 100644 --- a/server/source/system/casbin.go +++ b/server/source/system/casbin.go @@ -117,6 +117,38 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error {Ptype: "p", V0: "888", V1: "/system/getSystemConfig", V2: "POST"}, {Ptype: "p", V0: "888", V1: "/system/setSystemConfig", V2: "POST"}, {Ptype: "p", V0: "888", V1: "/system/getServerInfo", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/dashboard/stats", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-users", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-users", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-users/:id", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-users/:id/overview", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-users/:id", V2: "PATCH"}, + {Ptype: "p", V0: "888", V1: "/admin/app-users/:id/reset-password", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-users/:id/toggle-enable", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-users/:id", V2: "DELETE"}, + {Ptype: "p", V0: "888", V1: "/admin/app-users/:id/recharge", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-asset-transactions", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-asset-transactions/:id", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders/:id", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders/:id/approve", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders/:id/reject", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders/:id/mark-paid", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-withdraw-orders/:id/mark-failed", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-risk-tags", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-risk-tags", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-risk-tags/:id", V2: "PUT"}, + {Ptype: "p", V0: "888", V1: "/admin/app-risk-tags/:id", V2: "DELETE"}, + {Ptype: "p", V0: "888", V1: "/admin/app-users/:id/risk-tags", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-users/:id/risk-tags", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-user-risk-tags/:id", V2: "DELETE"}, + {Ptype: "p", V0: "888", V1: "/admin/app-login-logs", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-invite-codes", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-invite-codes/:id", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/admin/app-invite-codes/:id/revoke", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-invite-codes/issue", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/admin/app-invite-codes/clear-unused", V2: "POST"}, {Ptype: "p", V0: "888", V1: "/mcp/status", V2: "GET"}, {Ptype: "p", V0: "888", V1: "/mcp/start", V2: "POST"}, @@ -176,6 +208,7 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error {Ptype: "p", V0: "888", V1: "/sysParams/findSysParams", V2: "GET"}, {Ptype: "p", V0: "888", V1: "/sysParams/getSysParamsList", V2: "GET"}, {Ptype: "p", V0: "888", V1: "/sysParams/getSysParam", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/sysParams/getChangeLogList", V2: "GET"}, {Ptype: "p", V0: "888", V1: "/attachmentCategory/getCategoryList", V2: "GET"}, {Ptype: "p", V0: "888", V1: "/attachmentCategory/addCategory", V2: "POST"}, {Ptype: "p", V0: "888", V1: "/attachmentCategory/deleteCategory", V2: "POST"}, @@ -216,6 +249,39 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error {Ptype: "p", V0: "8881", V1: "/system/getSystemConfig", V2: "POST"}, {Ptype: "p", V0: "8881", V1: "/system/setSystemConfig", V2: "POST"}, {Ptype: "p", V0: "8881", V1: "/user/getUserInfo", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/dashboard/stats", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-users", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-users", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-users/:id", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/overview", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-users/:id", V2: "PATCH"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/reset-password", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/toggle-enable", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-users/:id", V2: "DELETE"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/recharge", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-asset-transactions", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-asset-transactions/:id", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders/:id", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders/:id/approve", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders/:id/reject", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders/:id/mark-paid", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-withdraw-orders/:id/mark-failed", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-risk-tags", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-risk-tags", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-risk-tags/:id", V2: "PUT"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-risk-tags/:id", V2: "DELETE"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/risk-tags", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-users/:id/risk-tags", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-user-risk-tags/:id", V2: "DELETE"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-login-logs", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-invite-codes", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-invite-codes/:id", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-invite-codes/:id/revoke", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-invite-codes/issue", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/admin/app-invite-codes/clear-unused", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/sysParams/getChangeLogList", V2: "GET"}, {Ptype: "p", V0: "9528", V1: "/user/admin_register", V2: "POST"}, {Ptype: "p", V0: "9528", V1: "/api/createApi", V2: "POST"}, @@ -260,7 +326,42 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error {Ptype: "p", V0: "9528", V1: "/mcp/tools", V2: "GET"}, {Ptype: "p", V0: "9528", V1: "/mcp/test", V2: "POST"}, {Ptype: "p", V0: "9528", V1: "/mcp/createTool", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/user/invite-code/generate", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/user/invite-code/current", V2: "GET"}, {Ptype: "p", V0: "9528", V1: "/user/getUserInfo", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/dashboard/stats", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-users", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-users", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-users/:id", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/overview", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-users/:id", V2: "PATCH"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/reset-password", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/toggle-enable", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-users/:id", V2: "DELETE"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/recharge", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-asset-transactions", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-asset-transactions/:id", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders/:id", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders/:id/approve", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders/:id/reject", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders/:id/mark-paid", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-withdraw-orders/:id/mark-failed", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-risk-tags", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-risk-tags", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-risk-tags/:id", V2: "PUT"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-risk-tags/:id", V2: "DELETE"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/risk-tags", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-users/:id/risk-tags", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-user-risk-tags/:id", V2: "DELETE"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-login-logs", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-invite-codes", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-invite-codes/:id", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-invite-codes/:id/revoke", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-invite-codes/issue", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/admin/app-invite-codes/clear-unused", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/sysParams/getChangeLogList", V2: "GET"}, } if err := db.Create(&entities).Error; err != nil { return ctx, errors.Wrap(err, "Casbin 表 ("+i.InitializerName()+") 数据初始化失败!") diff --git a/server/source/system/menu_repair.go b/server/source/system/menu_repair.go new file mode 100644 index 0000000..e7afd1e --- /dev/null +++ b/server/source/system/menu_repair.go @@ -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 +} + diff --git a/server/source/system/user.go b/server/source/system/user.go index 68b0b40..251b9ab 100644 --- a/server/source/system/user.go +++ b/server/source/system/user.go @@ -2,6 +2,7 @@ package system import ( "context" + sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system" "git.echol.cn/loser/Go-Web-Template/server/service/system" "git.echol.cn/loser/Go-Web-Template/server/utils" @@ -59,21 +60,21 @@ func (i *initUser) InitializeData(ctx context.Context) (next context.Context, er UUID: uuid.New(), Username: "admin", Password: adminPassword, - NickName: "Mr.奇淼", - HeaderImg: "https://qmplusimg.henrongyi.top/gva_header.jpg", + NickName: "超级管理员", + HeaderImg: "https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y", AuthorityId: 888, - Phone: "17611111111", - Email: "333333333@qq.com", + Phone: "13888888888", + Email: "admin@example.com", }, { UUID: uuid.New(), - Username: "a303176530", + Username: "JohnDoe", Password: password, - NickName: "用户1", - HeaderImg: "https://qmplusimg.henrongyi.top/1572075907logo.png", + NickName: "测试用户", + HeaderImg: "https://gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y", AuthorityId: 9528, - Phone: "17611111111", - Email: "333333333@qq.com"}, + Phone: "13999999999", + Email: "test@example.com"}, } if err = db.Create(&entities).Error; err != nil { return ctx, errors.Wrap(err, sysModel.SysUser{}.TableName()+"表数据初始化失败!") diff --git a/server/utils/claims.go b/server/utils/claims.go index 41eb4f1..bd2a0e9 100644 --- a/server/utils/claims.go +++ b/server/utils/claims.go @@ -136,7 +136,7 @@ func GetUserName(c *gin.Context) string { func LoginToken(user system.Login) (token string, claims systemReq.CustomClaims, err error) { j := NewJWT() - claims = j.CreateClaims(systemReq.BaseClaims{ + claims = j.CreateAdminClaims(systemReq.BaseClaims{ UUID: user.GetUUID(), ID: user.GetUserId(), NickName: user.GetNickname(), diff --git a/server/utils/jwt.go b/server/utils/jwt.go index da9307e..aecb2eb 100644 --- a/server/utils/jwt.go +++ b/server/utils/jwt.go @@ -14,6 +14,11 @@ type JWT struct { SigningKey []byte } +const ( + JWTAudienceAdmin = "GVA_ADMIN" + JWTAudienceApp = "GVA_APP" +) + var ( TokenValid = errors.New("未知错误") TokenExpired = errors.New("token已过期") @@ -29,14 +34,14 @@ func NewJWT() *JWT { } } -func (j *JWT) CreateClaims(baseClaims request.BaseClaims) request.CustomClaims { +func (j *JWT) CreateClaimsWithAudience(baseClaims request.BaseClaims, aud string) request.CustomClaims { bf, _ := ParseDuration(global.GVA_CONFIG.JWT.BufferTime) ep, _ := ParseDuration(global.GVA_CONFIG.JWT.ExpiresTime) claims := request.CustomClaims{ BaseClaims: baseClaims, BufferTime: int64(bf / time.Second), // 缓冲时间1天 缓冲时间内会获得新的token刷新令牌 此时一个用户会存在两个有效令牌 但是前端只留一个 另一个会丢失 RegisteredClaims: jwt.RegisteredClaims{ - Audience: jwt.ClaimStrings{"GVA"}, // 受众 + Audience: jwt.ClaimStrings{aud}, // 受众 NotBefore: jwt.NewNumericDate(time.Now().Add(-1000)), // 签名生效时间 ExpiresAt: jwt.NewNumericDate(time.Now().Add(ep)), // 过期时间 7天 配置文件 Issuer: global.GVA_CONFIG.JWT.Issuer, // 签名的发行者 @@ -45,6 +50,14 @@ func (j *JWT) CreateClaims(baseClaims request.BaseClaims) request.CustomClaims { return claims } +func (j *JWT) CreateAdminClaims(baseClaims request.BaseClaims) request.CustomClaims { + return j.CreateClaimsWithAudience(baseClaims, JWTAudienceAdmin) +} + +func (j *JWT) CreateAppClaims(baseClaims request.BaseClaims) request.CustomClaims { + return j.CreateClaimsWithAudience(baseClaims, JWTAudienceApp) +} + // CreateToken 创建一个token func (j *JWT) CreateToken(claims request.CustomClaims) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) diff --git a/web-admin/package-lock.json b/web-admin/package-lock.json index 32d3439..e1bb25d 100644 --- a/web-admin/package-lock.json +++ b/web-admin/package-lock.json @@ -16,8 +16,10 @@ "dayjs": "^1.11.20", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-markdown": "^10.1.0", "react-router-dom": "^7.14.0", "recharts": "^3.8.1", + "ua-parser-js": "^2.0.9", "zustand": "^5.0.12" }, "devDependencies": { @@ -159,6 +161,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -377,31 +380,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -409,7 +387,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1843,13 +1820,39 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1857,12 +1860,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1871,8 +1890,8 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1887,6 +1906,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -1938,6 +1963,7 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -2188,6 +2214,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -2220,6 +2252,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2358,6 +2391,16 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2409,6 +2452,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2467,6 +2511,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2484,6 +2538,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2525,6 +2619,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/compute-scroll-into-view": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", @@ -2704,13 +2808,13 @@ "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2730,6 +2834,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2746,6 +2863,35 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2756,6 +2902,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2861,6 +3020,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3029,6 +3189,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3045,6 +3215,12 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3329,6 +3505,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -3346,6 +3562,16 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3393,6 +3619,12 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -3402,6 +3634,40 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3425,12 +3691,54 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-mobile": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", "license": "MIT" }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3822,6 +4130,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3841,6 +4159,601 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3879,7 +4792,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -3978,6 +4890,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4011,6 +4948,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4057,6 +4995,16 @@ "node": ">= 0.8.0" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -4081,6 +5029,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4090,6 +5039,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4104,11 +5054,39 @@ "license": "MIT", "peer": true }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4199,7 +5177,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -4210,6 +5189,39 @@ "redux": "^5.0.0" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -4331,12 +5343,36 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", "license": "MIT" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4350,6 +5386,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", @@ -4401,6 +5455,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4441,6 +5515,7 @@ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4473,6 +5548,57 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "version": "2.0.9", + "resolved": "https://registry.npmmirror.com/ua-parser-js/-/ua-parser-js-2.0.9.tgz", + "integrity": "sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "AGPL-3.0-or-later", + "dependencies": { + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "ua-is-frozen": "^0.1.2" + }, + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -4480,6 +5606,93 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4530,6 +5743,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -4558,6 +5799,7 @@ "integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -4682,6 +5924,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -4727,6 +5970,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/web-admin/package.json b/web-admin/package.json index c3a330e..31d057d 100644 --- a/web-admin/package.json +++ b/web-admin/package.json @@ -21,6 +21,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^7.14.0", "recharts": "^3.8.1", + "ua-parser-js": "^2.0.9", "zustand": "^5.0.12" }, "devDependencies": { diff --git a/web-admin/src/assets/avatar_user.jpg b/web-admin/src/assets/avatar_user.jpg new file mode 100644 index 0000000000000000000000000000000000000000..efca5c22396e46b6c493165d3f6ec5fd26de1d9d GIT binary patch literal 1262 zcmex=_1P|rX?qqI0P zFI~aY%U!`Mz|~!$%*;qrN1?DZF(6Oj-S5fuR$!pIEN!@|nR z%E~Fi%grl7GWdUhL6CzXfFXdHQHg;`kdaxC@&6G9c?JeXR-jiwzJ&rtCZHSH*f}`4 zxPc0`3NSD+GBY!=FoRqTR9y>{XJ8Rz6;d>GWD^cdWLGK_F>0K+kVDyN<3Z7&iyu^s zlZu)+xx~aJB&Af<)HO7@(s#)lOnKGb1qam<1W^8Gb$L(pbQR6>DGqC;dZCIbUpjy}-o#&$f3S zbCAEUyz|SS7tGz4Yed(+y2zQk0-f??V+st$Q;ueNLR}n^$ z3rjxQ6ltWCYMrWXz7l>eYVPtIrqZjE&rjBNpTAIP-=y_(w!WUa=h|1!3r|&cOuX`2 z%whqTNuVo(2oQ_Dz0bcVSiZw##e<2*Z?B!A(&ewi&e;C-`+Bi$TW5VrZM&yFE9}L@ zl}YuCvHk7;EVJi-Y2+;3zddH9#}&B{Iqv%Qy%oJ>_Y{6qKC;wT z+c}{(aK@8UeddDyULJp<-M4e=taE8?kJQ^jUro{svS)S>1p1aC^sTV_{J1L*Hf@*R zGv)8&i|$2rO)IW1{Ud#O%C?0yhjn%*I=`G3{huLq0pH`p_oOy`xVxb3n&F=Qd1><2 z^Nas#Cv|BsECS*+^#VV#P9K@pCcWa=2RqLxH7{!WzpU-jV1BNf?=j!$%JNM+ = { + offline: '线下充值', + online_not_arrived: '线上未到账', + activity_subsidy: '活动补贴', + manual_adjustment: '人工调整', + } + return rechargeType ? map[String(rechargeType)] ?? String(rechargeType) : '-' +} + +export function AppAssetTransactionsPage() { + const [form] = Form.useForm() + const [rows, setRows] = useState([]) + 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(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 = 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 {yuan} + }, + }, + { + 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 ? {v} : '-'), + }, + { + 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) => ( + + ), + }, + ], + [] + ) + + return ( +
+ + +
{ + if (page !== 1) { + setPage(1) + return + } + load() + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + { + setPage(nextPage) + setPageSize(nextPageSize) + }, + }} + /> + + + { + setDrawerOpen(false) + setDrawerRecord(null) + }} + > + {drawerLoading ? ( + 加载中... + ) : 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 ( + + + + + + 玩家 + + {drawerRecord.username}(#{drawerRecord.appUserId}) + + + + + + + 资产类型 +
+ {formatAssetType(drawerRecord.assetType)} +
+ + + 变动类型 +
+ {formatRechargeType(drawerRecord.rechargeType)} +
+ + + 变动金额 +
+ + {isPlus ? '+' : ''} + {deltaYuan} + + + 0 ? 'blue' : drawerRecord.deltaLi < 0 ? 'red' : 'default'} style={{ marginInlineStart: 8 }}> + {drawerRecord.deltaLi > 0 ? '增加' : drawerRecord.deltaLi < 0 ? '减少' : '不变'} + +
+ + + 变动前 → 变动后 +
+ + {beforeYuan} → {afterYuan} + + (元) +
+ + + + + + + + + + + + {drawerRecord.reason || '-'} + + + {operator} + {formatDate(drawerRecord.createdAt)} + + 1 元 = 1000 厘 + + + + ) + })() + ) : ( + 暂无数据 + )} + + + ) +} + diff --git a/web-admin/src/features/appLoginLogs/AppLoginLogsPage.tsx b/web-admin/src/features/appLoginLogs/AppLoginLogsPage.tsx new file mode 100644 index 0000000..fc392e7 --- /dev/null +++ b/web-admin/src/features/appLoginLogs/AppLoginLogsPage.tsx @@ -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() + const [rows, setRows] = useState([]) + 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 = 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 ? {v} : '-'), + }, + { + title: '客户端信息', + dataIndex: 'userAgent', + ellipsis: true, + width: 280, + render: (v?: string) => { + const info = formatClientInfo(v) + return info.detail ? ( + + {info.summary} + + ) : ( + {info.summary} + ) + }, + }, + { + title: '状态', + width: 110, + render: (_, r) => {r.status ? '成功' : '失败'}, + }, + { + title: '失败原因', + dataIndex: 'errorMessage', + ellipsis: true, + render: (v: string | undefined, r) => (r.status ? '-' : v || '-'), + }, + { + title: '登录时间', + width: 190, + render: (_, r) => formatDate(r.createdAt), + }, + ], + [] + ) + + return ( +
+ +
+
+ + 登录日志 + + + 用于审计玩家登录行为(含失败原因、IP、参考地址与客户端信息)。 + +
+
+
+ + + +
{ + if (page !== 1) { + setPage(1) + return + } + load() + }} + > + + + + + + + +
{ + setPage(nextPage) + setPageSize(nextPageSize) + }, + }} + /> + + + + ) +} + diff --git a/web-admin/src/features/appPlayers/AppPlayersPage.tsx b/web-admin/src/features/appPlayers/AppPlayersPage.tsx new file mode 100644 index 0000000..adc9db2 --- /dev/null +++ b/web-admin/src/features/appPlayers/AppPlayersPage.tsx @@ -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() + const [createForm] = Form.useForm() + const [editForm] = Form.useForm() + const [resetPasswordForm] = Form.useForm() + const [rechargeForm] = Form.useForm() + const [loading, setLoading] = useState(false) + const [items, setItems] = useState([]) + 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(null) + const [resetOpen, setResetOpen] = useState(false) + const [resetting, setResetting] = useState(false) + const [resetRecord, setResetRecord] = useState(null) + const [rechargeOpen, setRechargeOpen] = useState(false) + const [recharging, setRecharging] = useState(false) + const [rechargeRecord, setRechargeRecord] = useState(null) + const rechargeType = Form.useWatch('rechargeType', rechargeForm) + + const [overviewOpen, setOverviewOpen] = useState(false) + const [overviewLoading, setOverviewLoading] = useState(false) + const [overview, setOverview] = useState(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 = [ + { title: 'ID', dataIndex: 'id', width: 90 }, + { title: '用户名', dataIndex: 'username', width: 180 }, + { title: '昵称', dataIndex: 'nickName', width: 160 }, + { + title: '账户余额(元)', + width: 140, + render: (_, record) => {formatLi(record.accountBalanceLi)}, + }, + { + title: '游戏币(元)', + width: 140, + render: (_, record) => {formatLi(record.gameCoinBalanceLi)}, + }, + { + title: '在线', + width: 100, + render: (_, record) => ( + {record.isOnline ? '在线' : '离线'} + ), + }, + { title: '最后登录', dataIndex: 'lastLoginAt', width: 190 }, + { title: '最后活跃', dataIndex: 'lastActiveAt', width: 190 }, + { + title: '状态', + width: 110, + render: (_, record) => ( + { + await appPlayersAdminApi.toggleEnable(record.id) + message.success(record.enable === 1 ? '已封禁' : '已解封') + load() + }} + /> + ), + }, + { + title: '操作', + key: 'actions', + fixed: 'right', + width: 370, + render: (_, record) => ( + + + + + + + + ), + }, + ] + + return ( +
+ + { + setPage(1) + load() + }} + > +
+ + + + + +
{ + setPage(nextPage) + setPageSize(nextPageSize) + }, + }} + /> + + + 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) + } + }} + > + + + + + + + + + + + + + + + + + + + + { + 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) + } + }} + > +
+ + + + + + + +
+ + { + 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) + } + }} + > +
+ + + + +
+ + { + 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) + } + }} + > +
{ + if (changed.rechargeType === 'activity_subsidy') { + rechargeForm.setFieldsValue({ assetType: 'game_coin' }) + } + }} + > + + + + { + const parsed = parseDeltaLiFromYuan(String(value ?? '')) + return parsed === null ? Promise.reject(new Error('请输入数字(支持正负与 3 位小数)')) : Promise.resolve() + }, + }, + ]} + > + + + + + + + 单位换算:1 元 = 1000 厘。明细会写入后台流水用于审计。 + + +
+ + { + setOverviewOpen(false) + setOverview(null) + }} + > + {overviewLoading ? ( + 加载中... + ) : overview ? ( + + + {overview.user.username} + {overview.user.nickName || '-'} + + {overview.user.enable === 1 ? '启用' : '封禁'} + + + + {overview.activity.isOnline ? '在线' : '离线'} + + + {formatLi(overview.asset.accountBalanceLi)} + {formatLi(overview.asset.gameCoinBalanceLi)} + {overview.activity.lastLoginAt || '-'} + {overview.activity.lastActiveAt || '-'} + {overview.activity.lastActiveIp || '-'} + + {overview.invite.inviterUsername ? `${overview.invite.inviterUsername}(#${overview.invite.inviterUserId})` : '-'} + + {overview.invite.invitedCount} + + {overview.invite.hasUnusedCode + ? `尾号 ${overview.invite.unusedCodeLast4 || '-'},过期时间:${overview.invite.unusedExpiresAt || '-'}` + : '无'} + + + + +
( + {r.status ? '成功' : '失败'} + ), + }, + { title: '原因', dataIndex: 'errorMessage' }, + ]} + dataSource={overview.recentLoginLogs} + /> + + + +
formatLi(r.deltaLi), + }, + { title: '原因', dataIndex: 'reason' }, + ]} + dataSource={overview.recentTx} + /> + + + ) : ( + 暂无数据 + )} + + + ) +} + diff --git a/web-admin/src/features/assetPolicyCenter/AssetPolicyCenterPage.tsx b/web-admin/src/features/assetPolicyCenter/AssetPolicyCenterPage.tsx new file mode 100644 index 0000000..4d85966 --- /dev/null +++ b/web-admin/src/features/assetPolicyCenter/AssetPolicyCenterPage.tsx @@ -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() + const [loading, setLoading] = useState(false) + const [records, setRecords] = useState>({}) + + 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 = {} + const values: Partial = {} + + 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> = [] + 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 ( +
+ +
+
+ + 资产策略中心 + + + 互转/提现/奖励去向等关键策略集中在此配置;所有修改会写入“参数变更审计”。 + +
+ + + + +
+ + + {warnings.length ? ( + + {warnings.map((w) => ( +
{w}
+ ))} +
+ } + /> + ) : null} + +
{ + // 触发 warnings 刷新 + setTimeout(() => {}, 0) + }} + > + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + diff --git a/web-admin/src/features/discovery/systemInventory.ts b/web-admin/src/features/discovery/systemInventory.ts index 2368ef3..37c7aa4 100644 --- a/web-admin/src/features/discovery/systemInventory.ts +++ b/web-admin/src/features/discovery/systemInventory.ts @@ -19,6 +19,51 @@ export const moduleCatalog: Record = { features: ['项目说明', '技术栈梳理', '外部链接入口'], 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: { name: 'superAdmin', title: '超级管理员', diff --git a/web-admin/src/features/inviteCodes/AdminInviteCodesPage.tsx b/web-admin/src/features/inviteCodes/AdminInviteCodesPage.tsx new file mode 100644 index 0000000..6455f05 --- /dev/null +++ b/web-admin/src/features/inviteCodes/AdminInviteCodesPage.tsx @@ -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 = { + 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() + const [issueForm] = Form.useForm() + const [clearForm] = Form.useForm() + + const [loading, setLoading] = useState(false) + const [items, setItems] = useState([]) + 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(null) + + const [issueOpen, setIssueOpen] = useState(false) + const [issuing, setIssuing] = useState(false) + const [issuedCode, setIssuedCode] = useState(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 = 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) => ( + + {statusLabel[value]} + + ), + }, + { 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) => ( + + + + + ), + }, + ], + [load], + ) + + return ( +
+ +
{ + setPage(1) + load() + }} + > +
+ + + + + + + + + + + + + + + + + + +
+ +
+ + +
{ + setPage(nextPage) + setPageSize(nextPageSize) + }, + }} + /> + + + { + setDetailOpen(false) + setDetail(null) + }} + width={520} + > + {detailLoading ? ( + 加载中... + ) : detail ? ( +
+ + +
邀请码ID:{detail.code.id}
+
末4位:{detail.code.codeLast4}
+
状态:{statusLabel[detail.code.status]}
+
过期时间:{detail.code.expiresAt || '-'}
+
创建时间:{detail.code.createdAt}
+
使用时间:{detail.code.usedAt || '-'}
+
+
+ + +
+ 邀请人:{detail.inviter.username}(ID={detail.inviter.id}) +
+
+ 被邀请人:{detail.invitee ? `${detail.invitee.username}(ID=${detail.invitee.id})` : '-'} +
+
+
+
+ ) : ( + 暂无数据 + )} +
+ + 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) + } + }} + > +
+ + + + + + + {issuedCode ? ( + + 邀请码(仅展示一次,请及时复制): +
{issuedCode}
+
+ ) : null} + +
+ + 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) + } + }} + > +
+ + + + +
() + const [rows, setRows] = useState([]) + 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 = [ + { title: 'ID', dataIndex: 'id', width: 90 }, + { title: 'Key', dataIndex: 'key', width: 260 }, + { + title: '动作', + dataIndex: 'action', + width: 110, + render: (v) => { + const map: Record = { + create: { label: '新建', color: 'green' }, + update: { label: '更新', color: 'blue' }, + delete: { label: '删除', color: 'red' }, + } + const it = map[String(v)] ?? { label: String(v), color: 'default' } + return {it.label} + }, + }, + { 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 ( +
+ +
+
+ + 参数变更审计 + + + 用于追溯运营开关/策略配置的变更记录(谁改的、改了什么)。 + +
+
+
+ + + + { + if (page !== 1) { + setPage(1) + return + } + load() + }} + > + + + + + + + + + + + + + + +
{ + setPage(nextPage) + setPageSize(nextPageSize) + }, + }} + /> + + + + ) +} + diff --git a/web-admin/src/features/registerPolicy/RegisterPolicyPage.tsx b/web-admin/src/features/registerPolicy/RegisterPolicyPage.tsx new file mode 100644 index 0000000..144cb93 --- /dev/null +++ b/web-admin/src/features/registerPolicy/RegisterPolicyPage.tsx @@ -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() + const [loading, setLoading] = useState(false) + const [records, setRecords] = useState>>( + {}, + ) + + 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 ( +
+ +
+
+ + 玩家注册策略 + +
+ + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
{ + setPage(p) + setPageSize(ps) + }, + }} + /> + + + setModalOpen(false)} onOk={save}> +
+ + + + { + const n = Number(value) + if (!Number.isFinite(n) || n < 1 || n > 5) throw new Error('风险级别只能为 1-5') + }, + }, + ]} + > + (rejectReason = e.target.value)} /> + (auditRemark = e.target.value)} /> + + ), + onOk: async () => { + const reason = rejectReason.trim() + if (!reason) throw new Error('请填写拒绝原因') + await doAction('已拒绝并解冻', () => appWithdrawOrdersAdminApi.reject(r.id, { rejectReason: reason, auditRemark: auditRemark.trim() || undefined })) + }, + }) + }} + > + 拒绝 + + + ) : null} + {r.status === 'approved' ? ( + <> + + + + ) : null} + + ), + }, + ], + [] + ) + + return ( +
+ + + { + if (page !== 1) { + setPage(1) + return + } + load() + }} + > + +
+ + + + + + + + + + + +
{ + setPage(nextPage) + setPageSize(nextPageSize) + }, + }} + /> + + + { + setDrawerOpen(false) + setDrawerRecord(null) + }} + > + {drawerLoading ? ( + 加载中... + ) : drawerRecord ? ( + + + + + + 玩家 + + {drawerRecord.username}(#{drawerRecord.appUserId}) + + {statusLabel(drawerRecord.status).text} + + + + + + 提现金额 +
+ + {formatLiToYuan(drawerRecord.amountLi)} + + +
+ + + 手续费 +
+ {formatLiToYuan(drawerRecord.feeLi)} 元 +
+ + + 实付 +
+ {formatLiToYuan(drawerRecord.netLi)} 元 +
+ + + + + + + + + 申请时间 + {formatDate(drawerRecord.createdAt)} + 审核时间 + {drawerRecord.approvedAt ? formatDate(drawerRecord.approvedAt) : '-'} + 打款时间 + {drawerRecord.paidAt ? formatDate(drawerRecord.paidAt) : '-'} + 备注/原因 + + {drawerRecord.rejectReason || drawerRecord.failureReason || drawerRecord.auditRemark || drawerRecord.applyRemark || '-'} + + + + + ) : ( + 暂无数据 + )} + + + ) +} + diff --git a/web-admin/src/index.css b/web-admin/src/index.css index 5c8d225..c834276 100644 --- a/web-admin/src/index.css +++ b/web-admin/src/index.css @@ -68,6 +68,10 @@ img { grid-template-columns: 300px minmax(0, 1fr); } +.admin-shell.is-collapsed { + grid-template-columns: 88px minmax(0, 1fr); +} + .admin-sidebar { position: sticky; top: 0; @@ -78,9 +82,65 @@ img { background: linear-gradient(180deg, rgba(16, 37, 66, 0.96) 0%, rgba(19, 51, 84, 0.94) 100%); color: var(--text-light); - overflow-y: scroll; + overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.22) transparent; +} + +.admin-shell.is-collapsed .admin-sidebar { + width: 88px; + min-width: 88px; + padding: 18px 10px; +} + +.admin-shell.is-collapsed .admin-brand { + padding: 14px 10px; + border-radius: 20px; +} + +.admin-brand-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.admin-shell.is-collapsed .admin-brand-header { + justify-content: center; +} + +.admin-shell.is-collapsed .admin-brand-header > div { + display: none; +} + +.admin-shell.is-collapsed .admin-brand-header .ant-btn { + padding: 0; +} + +.admin-sidebar::-webkit-scrollbar { + width: 8px; +} + +.admin-sidebar::-webkit-scrollbar-track { + background: transparent; + border-radius: 999px; +} + +.admin-sidebar::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.14); + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.06); +} + +.admin-sidebar:hover::-webkit-scrollbar-thumb, +.admin-sidebar:focus-within::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.28); +} + +.admin-sidebar::-webkit-scrollbar-thumb:active { + background: rgba(255, 255, 255, 0.34); } .admin-brand { diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index 7592798..d527730 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -33,6 +33,7 @@ import type { AdminUserExpLogItem, LevelExpConfig, LoginLog, + AppLoginLogRecord, LoginResult, McpContent, McpManagedStatus, @@ -66,6 +67,14 @@ import type { CreateAnnouncementDto, AdminFeedbackListItem, FeedbackDetailResponse, + AppPlayerRecord, + AppInviteCodeRecord, + AppInviteCodeDetail, + AppAssetTransactionRecord, + AppPlayerOverview, + AppWithdrawOrderRecord, + AppRiskTagRecord, + AppUserRiskTagRecord, } from '@/types/system' export const authApi = { @@ -231,6 +240,69 @@ export const appUserAdminApi = { }, } +export const appPlayersAdminApi = { + getList(params: { page: number; pageSize: number; keyword?: string; isOnline?: boolean }) { + return http.get>('/admin/app-users', { params }) + }, + create(payload: { username: string; password: string; enable?: boolean; nickName?: string; welcomePhrase?: string }) { + return http.post>('/admin/app-users', payload) + }, + getById(id: number) { + return http.get(`/admin/app-users/${id}`) + }, + getOverview(id: number) { + return http.get(`/admin/app-users/${id}/overview`) + }, + update(id: number, payload: { enable?: boolean; welcomePhrase?: string }) { + return http.patch>(`/admin/app-users/${id}`, payload) + }, + resetPassword(id: number, payload: { newPassword: string }) { + return http.post>(`/admin/app-users/${id}/reset-password`, payload) + }, + toggleEnable(id: number) { + return http.post>(`/admin/app-users/${id}/toggle-enable`) + }, + delete(id: number) { + return http.delete>(`/admin/app-users/${id}`) + }, + recharge( + id: number, + payload: { + assetType: 'account' | 'game_coin' + rechargeType: 'offline' | 'online_not_arrived' | 'activity_subsidy' | 'manual_adjustment' + deltaLi: number + reason: string + } + ) { + return http.post>(`/admin/app-users/${id}/recharge`, payload) + }, +} + +export const appInviteCodesAdminApi = { + getList(params: { + page: number + pageSize: number + status?: string + createdByUserId?: number + usedByUserId?: number + codeLast4?: string + }) { + return http.get>('/admin/app-invite-codes', { params }) + }, + getById(id: number) { + return http.get(`/admin/app-invite-codes/${id}`) + }, + revoke(id: number) { + return http.post>(`/admin/app-invite-codes/${id}/revoke`) + }, + issue(payload: { userId: number; expireHours?: number }) { + return http.post<{ code: string; codeLast4: string; expiresAt?: string | null }>('/admin/app-invite-codes/issue', payload) + }, + clearUnused(payload: { userId: number; scope?: 'active' | 'all' }) { + return http.post>('/admin/app-invite-codes/clear-unused', payload) + }, +} + export const aiConfigApi = { getList() { return http.get('/admin/ai-config') @@ -404,6 +476,9 @@ export const sysParamsApi = { getParamsList(params?: Record) { return http.get>('/sysParams/getSysParamsList', { params }) }, + getChangeLogList(params?: Record) { + return http.get>>('/sysParams/getChangeLogList', { params }) + }, createParam(payload: Partial) { return http.post>('/sysParams/createSysParams', payload) }, @@ -427,6 +502,104 @@ export const loginLogApi = { }, } +export const appLoginLogAdminApi = { + getList(params: { + page: number + pageSize: number + username?: string + ip?: string + status?: boolean + startAt?: string + endAt?: string + }) { + return http.get>('/admin/app-login-logs', { params }) + }, +} + +export const appAssetTransactionsAdminApi = { + getList(params: { + page: number + pageSize: number + userId?: number + username?: string + assetType?: string + rechargeType?: string + operatorUserId?: number + keyword?: string + startAt?: string + endAt?: string + }) { + return http.get>('/admin/app-asset-transactions', { params }) + }, + getById(id: number) { + return http.get(`/admin/app-asset-transactions/${id}`) + }, +} + +export const appWithdrawOrdersAdminApi = { + getList(params: { + page: number + pageSize: number + userId?: number + username?: string + status?: string + startAt?: string + endAt?: string + }) { + return http.get>('/admin/app-withdraw-orders', { params }) + }, + getById(id: number) { + return http.get(`/admin/app-withdraw-orders/${id}`) + }, + create(payload: { + appUserId: number + amountLi: number + feeLi?: number + channel?: string + payeeAccount?: string + payeeRealName?: string + applyRemark?: string + }) { + return http.post>('/admin/app-withdraw-orders', payload) + }, + approve(id: number, payload?: { auditRemark?: string }) { + return http.post>(`/admin/app-withdraw-orders/${id}/approve`, payload ?? {}) + }, + reject(id: number, payload: { rejectReason: string; auditRemark?: string }) { + return http.post>(`/admin/app-withdraw-orders/${id}/reject`, payload) + }, + markPaid(id: number, payload?: { externalTxId?: string }) { + return http.post>(`/admin/app-withdraw-orders/${id}/mark-paid`, payload ?? {}) + }, + markFailed(id: number, payload: { failureReason: string; externalTxId?: string }) { + return http.post>(`/admin/app-withdraw-orders/${id}/mark-failed`, payload) + }, +} + +export const appRiskTagsAdminApi = { + getTags(params: { page: number; pageSize: number; keyword?: string }) { + return http.get>('/admin/app-risk-tags', { params }) + }, + createTag(payload: { name: string; level: number; color?: string; desc?: string }) { + return http.post>('/admin/app-risk-tags', payload) + }, + updateTag(id: number, payload: { name: string; level: number; color?: string; desc?: string }) { + return http.put>(`/admin/app-risk-tags/${id}`, payload) + }, + deleteTag(id: number) { + return http.delete>(`/admin/app-risk-tags/${id}`) + }, + getUserTags(userId: number) { + return http.get(`/admin/app-users/${userId}/risk-tags`) + }, + addUserTag(userId: number, payload: { tagId: number; remark?: string }) { + return http.post>(`/admin/app-users/${userId}/risk-tags`, payload) + }, + removeUserTag(relationId: number) { + return http.delete>(`/admin/app-user-risk-tags/${relationId}`) + }, +} + export const operationApi = { getOperationList(params?: Record) { return http.get>('/sysOperationRecord/getSysOperationRecordList', { params }) diff --git a/web-admin/src/lib/http.ts b/web-admin/src/lib/http.ts index ad82522..2a823f7 100644 --- a/web-admin/src/lib/http.ts +++ b/web-admin/src/lib/http.ts @@ -57,6 +57,9 @@ export const http = { put(url: string, data?: unknown, config?: AxiosRequestConfig) { return request({ ...config, url, data, method: 'put' }) }, + patch(url: string, data?: unknown, config?: AxiosRequestConfig) { + return request({ ...config, url, data, method: 'patch' }) + }, delete(url: string, config?: AxiosRequestConfig) { return request({ ...config, url, method: 'delete' }) }, diff --git a/web-admin/src/router/pages/financeCenter/appAssetTransactions/page.tsx b/web-admin/src/router/pages/financeCenter/appAssetTransactions/page.tsx new file mode 100644 index 0000000..f185197 --- /dev/null +++ b/web-admin/src/router/pages/financeCenter/appAssetTransactions/page.tsx @@ -0,0 +1,8 @@ +import { AppAssetTransactionsPage } from '@/features/appAssetTransactions/AppAssetTransactionsPage' + +export const routeMeta = { + menuName: 'financeAppAssetTransactions', +} + +export default AppAssetTransactionsPage + diff --git a/web-admin/src/router/pages/financeCenter/page.tsx b/web-admin/src/router/pages/financeCenter/page.tsx new file mode 100644 index 0000000..3596910 --- /dev/null +++ b/web-admin/src/router/pages/financeCenter/page.tsx @@ -0,0 +1,8 @@ +import { createModulePage } from '@/router/createModulePage' + +export const routeMeta = { + menuName: 'financeCenter', +} + +export default createModulePage('financeCenter') + diff --git a/web-admin/src/router/pages/financeCenter/withdrawManage/page.tsx b/web-admin/src/router/pages/financeCenter/withdrawManage/page.tsx new file mode 100644 index 0000000..b407d8f --- /dev/null +++ b/web-admin/src/router/pages/financeCenter/withdrawManage/page.tsx @@ -0,0 +1,8 @@ +import { createModulePage } from '@/router/createModulePage' + +export const routeMeta = { + menuName: 'withdrawManage', +} + +export default createModulePage('withdrawManage') + diff --git a/web-admin/src/router/pages/financeCenter/withdrawManage/withdrawOrders/page.tsx b/web-admin/src/router/pages/financeCenter/withdrawManage/withdrawOrders/page.tsx new file mode 100644 index 0000000..60191a5 --- /dev/null +++ b/web-admin/src/router/pages/financeCenter/withdrawManage/withdrawOrders/page.tsx @@ -0,0 +1,8 @@ +import { WithdrawOrdersPage } from '@/features/withdrawOrders/WithdrawOrdersPage' + +export const routeMeta = { + menuName: 'withdrawOrders', +} + +export default WithdrawOrdersPage + diff --git a/web-admin/src/router/pages/gameCenter/page.tsx b/web-admin/src/router/pages/gameCenter/page.tsx new file mode 100644 index 0000000..a4f2d43 --- /dev/null +++ b/web-admin/src/router/pages/gameCenter/page.tsx @@ -0,0 +1,8 @@ +import { createModulePage } from '@/router/createModulePage' + +export const routeMeta = { + menuName: 'gameCenter', +} + +export default createModulePage('gameCenter') + diff --git a/web-admin/src/router/pages/opsConfig/assetPolicyCenter/page.tsx b/web-admin/src/router/pages/opsConfig/assetPolicyCenter/page.tsx new file mode 100644 index 0000000..195963e --- /dev/null +++ b/web-admin/src/router/pages/opsConfig/assetPolicyCenter/page.tsx @@ -0,0 +1,8 @@ +import { AssetPolicyCenterPage } from '@/features/assetPolicyCenter/AssetPolicyCenterPage' + +export const routeMeta = { + menuName: 'assetPolicyCenter', +} + +export default AssetPolicyCenterPage + diff --git a/web-admin/src/router/pages/opsConfig/page.tsx b/web-admin/src/router/pages/opsConfig/page.tsx new file mode 100644 index 0000000..4b3c546 --- /dev/null +++ b/web-admin/src/router/pages/opsConfig/page.tsx @@ -0,0 +1,8 @@ +import { createModulePage } from '@/router/createModulePage' + +export const routeMeta = { + menuName: 'opsConfig', +} + +export default createModulePage('opsConfig') + diff --git a/web-admin/src/router/pages/opsConfig/riskTags/page.tsx b/web-admin/src/router/pages/opsConfig/riskTags/page.tsx new file mode 100644 index 0000000..a30da16 --- /dev/null +++ b/web-admin/src/router/pages/opsConfig/riskTags/page.tsx @@ -0,0 +1,8 @@ +import { RiskTagsPage } from '@/features/riskTags/RiskTagsPage' + +export const routeMeta = { + menuName: 'riskTags', +} + +export default RiskTagsPage + diff --git a/web-admin/src/router/pages/opsConfig/sysParamChangeLog/page.tsx b/web-admin/src/router/pages/opsConfig/sysParamChangeLog/page.tsx new file mode 100644 index 0000000..ac3792d --- /dev/null +++ b/web-admin/src/router/pages/opsConfig/sysParamChangeLog/page.tsx @@ -0,0 +1,7 @@ +import { SysParamChangeLogPage } from '@/features/params/SysParamChangeLogPage' + +export const routeMeta = { + menuName: 'sysParamChangeLog', +} + +export default SysParamChangeLogPage diff --git a/web-admin/src/router/pages/userCenter/appAssetTransactions/page.tsx b/web-admin/src/router/pages/userCenter/appAssetTransactions/page.tsx new file mode 100644 index 0000000..2d4a50a --- /dev/null +++ b/web-admin/src/router/pages/userCenter/appAssetTransactions/page.tsx @@ -0,0 +1,8 @@ +import { AppAssetTransactionsPage } from '@/features/appAssetTransactions/AppAssetTransactionsPage' + +export const routeMeta = { + menuName: 'appAssetTransactions', +} + +export default AppAssetTransactionsPage + diff --git a/web-admin/src/router/pages/userCenter/appLoginLog/page.tsx b/web-admin/src/router/pages/userCenter/appLoginLog/page.tsx new file mode 100644 index 0000000..e8038b5 --- /dev/null +++ b/web-admin/src/router/pages/userCenter/appLoginLog/page.tsx @@ -0,0 +1,7 @@ +import { AppLoginLogsPage } from '@/features/appLoginLogs/AppLoginLogsPage' + +export const routeMeta = { + menuName: 'appLoginLog', +} + +export default AppLoginLogsPage diff --git a/web-admin/src/router/pages/userCenter/appPlayers/page.tsx b/web-admin/src/router/pages/userCenter/appPlayers/page.tsx new file mode 100644 index 0000000..783861e --- /dev/null +++ b/web-admin/src/router/pages/userCenter/appPlayers/page.tsx @@ -0,0 +1,8 @@ +import { AppPlayersPage } from '@/features/appPlayers/AppPlayersPage' + +export const routeMeta = { + menuName: 'appPlayers', +} + +export default AppPlayersPage + diff --git a/web-admin/src/router/pages/userCenter/inviteCodes/page.tsx b/web-admin/src/router/pages/userCenter/inviteCodes/page.tsx new file mode 100644 index 0000000..5f35350 --- /dev/null +++ b/web-admin/src/router/pages/userCenter/inviteCodes/page.tsx @@ -0,0 +1,8 @@ +import { AdminInviteCodesPage } from '@/features/inviteCodes/AdminInviteCodesPage' + +export const routeMeta = { + menuName: 'inviteCodes', +} + +export default AdminInviteCodesPage + diff --git a/web-admin/src/router/pages/userCenter/page.tsx b/web-admin/src/router/pages/userCenter/page.tsx new file mode 100644 index 0000000..07c2ee9 --- /dev/null +++ b/web-admin/src/router/pages/userCenter/page.tsx @@ -0,0 +1,8 @@ +import { createModulePage } from '@/router/createModulePage' + +export const routeMeta = { + menuName: 'userCenter', +} + +export default createModulePage('userCenter') + diff --git a/web-admin/src/router/pages/userCenter/registerPolicy/page.tsx b/web-admin/src/router/pages/userCenter/registerPolicy/page.tsx new file mode 100644 index 0000000..213d382 --- /dev/null +++ b/web-admin/src/router/pages/userCenter/registerPolicy/page.tsx @@ -0,0 +1,8 @@ +import { RegisterPolicyPage } from '@/features/registerPolicy/RegisterPolicyPage' + +export const routeMeta = { + menuName: 'registerPolicy', +} + +export default RegisterPolicyPage + diff --git a/web-admin/src/types/system.ts b/web-admin/src/types/system.ts index 04e4c6f..fd06450 100644 --- a/web-admin/src/types/system.ts +++ b/web-admin/src/types/system.ts @@ -86,6 +86,133 @@ export type AppUserDetail = { messageCount: number } +export type AppPlayerRecord = { + id: number + uuid: string + username: string + enable: number + nickName?: string + welcomePhrase?: string + accountBalanceLi?: number + gameCoinBalanceLi?: number + lastLoginAt?: string | null + lastActiveAt?: string | null + isOnline?: boolean +} + +export type AppPlayerOverview = { + user: { + id: number + uuid: string + username: string + enable: number + nickName?: string + } + asset: { accountBalanceLi: number; gameCoinBalanceLi: number } + activity: { + lastLoginAt?: string | null + lastActiveAt?: string | null + lastActiveIp?: string + isOnline: boolean + } + recentLoginLogs: AppLoginLogRecord[] + recentTx: Array<{ + id: number + appUserId: number + assetType: string + rechargeType: string + deltaLi: number + beforeLi: number + afterLi: number + reason: string + operatorUserId: number + createdAt: string + }> + invite: { + inviterUserId?: number + inviterUsername?: string + invitedCount: number + hasUnusedCode: boolean + unusedCodeLast4?: string + unusedExpiresAt?: string | null + } +} + +export type AppInviteCodeStatus = 'unused' | 'used' | 'revoked' | 'expired' + +export type AppInviteCodeRecord = { + id: number + createdByUserId: number + codeLast4: string + status: AppInviteCodeStatus + expiresAt?: string | null + usedByUserId?: number | null + usedAt?: string | null + createdAt: string +} + +export type AppInviteCodeDetail = { + code: { + id: number + createdByUserId: number + codeLast4: string + status: AppInviteCodeStatus + expiresAt?: string | null + usedByUserId?: number | null + usedAt?: string |null + createdAt: string + updatedAt: string + } + inviter: { id: number; username: string } + invitee?: { id: number; username: string } + relation?: { + inviterUserId: number + inviteeUserId: number + inviteCodeId: number + createdAt: string + } +} + +export type AppWithdrawOrderStatus = 'pending' | 'approved' | 'rejected' | 'paid' | 'failed' | string + +export type AppWithdrawOrderRecord = { + id: number + appUserId: number + username: string + amountLi: number + feeLi: number + netLi: number + status: AppWithdrawOrderStatus + operatorUserId: number + approvedBy?: number + approvedAt?: string | null + paidBy?: number + paidAt?: string | null + createdAt: string +} + +export type AppRiskTagRecord = { + id: number + name: string + level: number + color?: string + desc?: string + createdAt: string + updatedAt: string +} + +export type AppUserRiskTagRecord = { + id: number + appUserId: number + tagId: number + operatorUserId: number + remark?: string + createdAt: string + tagName?: string + tagLevel?: number + tagColor?: string +} + export type RiskReviewStatus = 'none' | 'pending_review' | 'reviewed' export type RiskAccountRecord = { @@ -324,6 +451,33 @@ export type LoginLog = BaseEntity & { agent?: string } +export type AppLoginLogRecord = { + id: number + appUserId: number + username: string + ip: string + referer?: string + userAgent?: string + status: boolean + errorMessage?: string + createdAt: string +} + +export type AppAssetTransactionRecord = { + id: number + appUserId: number + username: string + assetType: 'account' | 'game_coin' | string + rechargeType: 'offline' | 'online_not_arrived' | 'activity_subsidy' | 'manual_adjustment' | string + deltaLi: number + beforeLi: number + afterLi: number + reason: string + operatorUserId: number + operatorName?: string + createdAt: string +} + export type OperationRecord = BaseEntity & { ip: string method: string diff --git a/后台里程碑计划(无需用户前端).md b/后台里程碑计划(无需用户前端).md new file mode 100644 index 0000000..74f0f6f --- /dev/null +++ b/后台里程碑计划(无需用户前端).md @@ -0,0 +1,200 @@ +# 后台里程碑计划(无需用户前端参与) + +本文档将“无需用户前端参与、可在管理后台优先落地”的能力,拆分为可交付的里程碑(Milestone)。每个里程碑都包含: + +- **交付页面**:管理后台需要新增/完善的页面 +- **后端能力**:表结构、接口、服务逻辑、审计/权限点 +- **权限与菜单**:API 元数据、Casbin 策略、菜单组件接入 +- **最小验证**:后端/前端最低验证与人工验收点 + +> 适用前提:当前后端已具备玩家(`app_user`)、资产余额与流水、活跃/在线判定、注册策略/邀请码、登录日志等基础能力;用户/游戏前端尚未启动。 + +--- + +## Milestone 0:治理底座(1-2 天,强烈建议先做) + +### 交付页面 +- **策略变更审计页**(可选先做列表页) + - 展示:谁在何时改了什么配置(注册策略/互转/提现/奖励去向等) + +### 后端能力 +- **策略变更记录表**(建议): + - 字段:配置 Key、旧值、新值、操作者、原因、时间、来源(页面/接口) +- **通用审计写入**: + - 对所有“运营开关/策略配置”变更统一写审计 + +### 权限与菜单 +- 新增 API 元数据(`server/source/system/api.go`) +- Casbin 默认策略(`server/source/system/casbin.go`) +- 新增页面组件到菜单组件目录(`web-admin/src/features/menus/menuComponentCatalog.ts`) + +### 最小验证 +- 后端 `go test ./...` +- 前端 `npm run build` +- 人工:修改一次策略→审计页可查到记录 + +--- + +## Milestone 1:玩家资产流水中心(2-4 天,高性价比) + +### 交付页面 +- **玩家资产流水列表页** + - 列:流水ID、玩家ID/用户名、资产类型、变动金额、变动前/后、变动类型(充值类型/来源)、原因、操作人、时间 + - 筛选:玩家关键字、时间范围、资产类型、充值类型/来源、操作人、原因关键字 + - 功能:查看详情(drawer/modal),必要时支持复制关键字段用于对账 + +### 后端能力 +- **流水查询接口(分页)** + - `GET /admin/app-asset-transactions` + - 支持筛选条件(见上) +- **流水详情接口(可选)** + - `GET /admin/app-asset-transactions/:id` +- **约束** + - 所有资产变动必须写流水(已有基础:充值写流水) + +### 权限与菜单 +- API 元数据 + Casbin 策略补齐 +- 菜单:建议放在 **用户中心** 或 **财务中心**(取决于你希望谁用得更多) + +### 最小验证 +- 人工:对某玩家执行一次“充值/冲正/调账”(若已有)→流水中心可查到 +- 后端/前端构建通过 + +--- + +## Milestone 2:玩家详情聚合页(One-page Debug)(2-4 天,提升效率) + +### 交付页面 +- **玩家详情页/抽屉** + - 概览:基础信息、状态、在线/活跃、余额与游戏币 + - 子模块(Tab 或分区): + - 最近登录日志(N 条) + - 最近资产流水(N 条) + - 邀请关系概览(邀请人/被邀请统计/邀请码状态) + - 运营备注(内部备注,可选) + +### 后端能力 +- **玩家聚合详情接口** + - `GET /admin/app-users/:id/overview`(建议新增) + - 返回“可直接渲染”的聚合数据,减少前端多请求 + - 注意:不要把复杂业务逻辑塞进 utils;聚合可放 service 层 + +### 权限与菜单 +- API 元数据 + Casbin 策略 +- 页面入口:玩家管理列表的“详情”按钮;也可在用户中心新增独立入口(可选) + +### 最小验证 +- 人工:打开任意玩家详情→能看到登录日志与流水摘要 +- 后端/前端构建通过 + +--- + +## Milestone 3:提现管理(后台流程先行)(3-6 天,财务主线) + +> 即使用户前端未提供提现申请入口,也建议先把后台提现流程与审计做扎实;未来接入只需增加“申请入口”。 + +### 交付页面 +- **提现订单列表** + - 状态:待审核/待打款/已完成/已驳回/失败 + - 筛选:玩家、时间范围、状态、金额区间 +- **提现订单详情** + - 包含审计:审核人、审核意见、打款凭证(可选)、失败原因 +- **操作** + - 审核通过/驳回、标记打款完成、失败回滚 + +### 后端能力 +- **提现订单表** + - 关键:状态机 + 并发控制(冻结余额/可用余额) +- **接口** + - `GET /admin/withdrawals`(分页) + - `GET /admin/withdrawals/:id` + - `POST /admin/withdrawals/:id/approve` + - `POST /admin/withdrawals/:id/reject` + - `POST /admin/withdrawals/:id/mark-paid` + - `POST /admin/withdrawals/:id/fail` +- **规则** + - 仅能提现账户余额(强校验) + - 提现与互转并发策略(提现处理中限制互转或冻结可用余额) +- **审计** + - 每次状态变更写操作记录(操作人、原因、前后状态) + +### 权限与菜单 +- 建议放 **财务中心** +- API 元数据 + Casbin + +### 最小验证 +- 人工:创建一笔测试订单(可用后台“代创建”接口或直接 DB 插入,仅用于联调)→完整走通状态机 +- 后端/前端构建通过 + +--- + +## Milestone 4:互转与奖励去向策略中心(2-4 天,运营开关落地) + +### 交付页面 +- **资产策略配置页** + - 互转:总开关、方向(余额→币 / 币→余额)、手续费、日限额、单笔限制 + - 胜利奖励去向:入账户余额 / 入游戏币 + - 提现:总开关、风控开关(可选) + - 页面必须展示“当前组合风险提示”(例如开启 币→余额 可能导致活动币间接提现) + +### 后端能力 +- 参数化配置(建议落在 `sys_params`): + - `asset.convert.enabled` + - `asset.convert.direction`(或拆分两个 bool) + - `asset.convert.fee_rate_or_li` + - `asset.convert.daily_limit_li` + - `asset.win_reward.destination` + - `asset.withdraw.enabled` +- 强校验:后端必须按开关拒绝(防抓包) +- 写入策略变更审计(复用 Milestone 0) + +### 权限与菜单 +- 建议放 **运营配置**(也可在用户中心镜像入口) +- API 元数据 + Casbin + +### 最小验证 +- 切换开关→接口行为与前端提示一致 +- 审计记录可查 + +--- + +## Milestone 5:风险标签与批量处置(人工版)(2-5 天,可持续演进) + +### 交付页面 +- 风险标签管理(标签字典) +- 玩家打标/备注/时间线 +- 批量处置:批量封禁/解封、限制操作(若未来做分域封控) + +### 后端能力 +- 风险标签表、玩家标签关系表 +- 接口:标签 CRUD、给玩家打标、批量打标/移除 +- 审计:关键操作写操作记录 + +### 权限与菜单 +- 建议放 **用户中心** 或 **运营配置** + +### 最小验证 +- 人工:给玩家打标→列表筛选可见→批量移除可用 + +--- + +## 推荐实施顺序(最短路径) + +若你希望“先稳住审计与对账,再扩展运营与财务”: + +1. **Milestone 1**(资产流水中心) +2. **Milestone 2**(玩家详情聚合) +3. **Milestone 0**(策略变更审计)*可与 1/2 并行* +4. **Milestone 3**(提现管理) +5. **Milestone 4**(策略中心) +6. **Milestone 5**(风险标签) + +--- + +## 验收口径(建议统一) + +- 页面可用:查询/筛选/分页/详情展示完整 +- 权限可控:菜单授权与 API 授权可配置,默认策略可用 +- 审计闭环:关键操作可追溯(谁、何时、为什么、改了什么) +- 最低验证:后端 `go test ./...`、前端 `npm run build` 通过 + diff --git a/玩家资产与提现规则分析.md b/玩家资产与提现规则分析.md new file mode 100644 index 0000000..141555b --- /dev/null +++ b/玩家资产与提现规则分析.md @@ -0,0 +1,168 @@ +# 玩家资产与提现规则:分析说明与建议(修订版) + +本文档根据你补充后的业务定稿,对**账户余额**与**游戏币**的关系、提现、互转、以及**仅因「玩游戏消耗游戏币」触发的胜利奖励**入账策略进行梳理,说明优势与劣势、是否完善、实现与运营注意点,并给出建议。 + +--- + +## 一、业务规则(定稿复述) + +### 1.1 提现范围 + +- **仅能提现账户余额**。 +- **游戏币余额不能提现**(含活动赠送进入游戏币的部分——因其始终落在游戏币池内)。 +- **游戏胜利后,奖励进入账户余额的部分,可以提现**(与「仅游戏币不可提现」不矛盾:可提现性由**资金落在哪个钱包**决定)。 + +### 1.2 活动赠送与游戏币 + +- **活动赠送固定进入游戏币**(或你当前实现中等价表述),且**不因「仍是游戏币」而允许提现**。 +- **游戏币不做子类型拆分**:系统只维护**单一游戏币余额**字段/池子,不区分「赠送币」「赢得币」等子账本。 + +### 1.3 游戏币与账户余额互转 + +- **玩家前端提供游戏币与账户余额的互转**,**是否启用、如何约束**由管理后台控制。 +- **互转比价固定为 1:1**(按你方「厘」或元换算规则在工程上保持一致即可)。 +- **手续费、每日上限、是否单向(仅余额→币 / 仅币→余额 / 双向)等**,不作为本文固定条款,**由后台运营管理通过配置实现**(参数化)。 + +### 1.4 胜利奖励入账策略 + +- 触发条件:**游戏币因「玩游戏」而被消耗**之后,产生的胜利奖励: + - 自动进入**账户余额**,或 + - 自动进入**游戏币余额**, + - **由管理后台配置决定**。 +- **「游戏币使用」的严格定义**:**仅指玩游戏导致的消耗**。 + **其他任何消耗**(例如商城、手续费、捐赠、转赠、其它业务扣款等)**均不触发**本条「胜利奖励入账策略」语义;若未来存在多种消耗,必须在产品/接口层与「游戏消耗」**显式区分**,避免规则被误用。 + +--- + +## 二、这套规则的优势 + +### 2.1 用户心智与出口清晰 + +- **想提现 → 看账户余额**;**游戏币池天然不可提现**,解释成本低。 +- **胜利奖励若进账户余额 → 可提现**:对「真钱回报」诉求的用户更友好,同时仍保留「奖励进游戏币」以沉淀站内经济的选择。 + +### 2.2 实现相对简单(无游戏币子类型) + +- 仅两池:**账户余额** + **游戏币**,表结构与接口更直观,前端展示简单。 +- 活动赠送、互转、胜利奖励都落到这两个字段之一,开发路径短。 + +### 2.3 运营弹性强 + +- **互转**与**胜利奖励去向**均可配置,便于分阶段运营(拉新、促活、控提现、控套利)。 +- **手续费、上限、单向互转**等风控手段交给运营配置,不必改代码即可调整策略。 + +### 2.4 与「营销成本」的隔离仍成立(在制度层面) + +- 活动赠送默认落在**不可提现的游戏币池**;只有当用户通过**你定义的游戏路径**形成奖励且**配置为入账账户余额**时,才进入可提现池——这在产品规则上是闭合的。 + +--- + +## 三、劣势与风险点(在「无游戏币子类型」前提下) + +### 3.1 互转可能把「不可提现」变成「可提现」(核心工程/运营风险) + +在**不拆游戏币子类型**的前提下,若后台允许 **游戏币 → 账户余额** 互转(或双向互转中的这一向),则: + +- 活动赠送的游戏币在**数值上**可以转入账户余额,从而**间接获得提现能力**。 + +这不是账本分录错误,而是**产品规则与「活动赠送永不可提现」的表述**在极端配置下可能冲突。 + +**缓解思路(不要求引入子类型,而是约束「动作集合」):** + +- 运营上:**活动期可关闭「币→余额」**,或仅开放「余额→币」。 +- 产品上:**互转与提现的风控参数**(单向、日限额、手续费、冷却)应作为**一等公民**配置,且变更留痕。 +- 技术上:所有互转、奖励入账、提现必须**写流水**,便于事后审计与争议处理。 + +### 3.2 「游戏消耗」边界必须严格编码 + +若「玩游戏消耗」与其它扣款共用同一套扣减接口,容易出现: + +- 误把非游戏扣款当作游戏消耗,从而**错误触发胜利奖励策略**。 + +需要在服务端用**明确的事件类型/业务单号/场景码**区分,而不能仅靠「余额变少」推断。 + +### 3.3 审计与客服仍依赖流水,而非依赖子类型 + +没有子类型时,**不能**从余额快照直接读出「这笔钱是不是活动送的」,但可以通过流水回答: + +- 该笔账户余额增加来源是否为「互转入账」「胜利奖励」「充值」等。 + +若流水缺失,纠纷处理会明显变难。 + +### 3.4 开关组合下的运营误配 + +典型误配示例: + +- **互转双向大开 + 胜利奖励进账户余额 + 高倍奖励**:可提现池增长过快。 +- **互转全关 + 奖励只进游戏币**:用户可能长期难以增加账户余额(若这是你期望的沉淀策略则合理,否则体验受损)。 + +需要后台配置页对「当前组合」给出**风险提示文案**。 + +--- + +## 四、是否完善?结论 + +### 4.1 作为业务制度:已闭环且可对外解释 + +在补充后,四条主线已经闭合: + +- 提现只认账户余额;游戏币不可提现;奖励进余额则可提现。 +- 互转 1:1,细则交给运营配置。 +- 「游戏消耗」定义收窄,避免与其它扣款混淆。 + +### 4.2 作为可长期运行的资金系统:仍依赖三类「工程契约」 + +若不落地以下契约,上线后容易踩坑: + +1. **互转方向与限额等参数的后端强制校验**(不仅前端隐藏)。 +2. **游戏消耗事件的明确定义与防误用**(独立业务类型/订单)。 +3. **全链路流水**(充值、活动发放、互转、游戏消耗、胜利奖励、提现冻结/成功/失败),否则无子类型时审计压力大。 + +**不引入游戏币子类型**是可以的,但要把风控压力转移到**互转策略 + 流水 + 运营配置纪律**上。 + +--- + +## 五、建议(与「无子类型」一致) + +### 5.1 参数化建议(后台运营管理) + +建议至少支持独立配置(名称可自定): + +- **互转总开关**(或分向:`余额→币`、`币→余额` 各自开关)。 +- **手续费**(可为 0)、**每日每用户限额**、**单笔最小/最大**(按需)。 +- **胜利奖励默认入账**:`account` / `game_coin`。 +- **提现总开关**、**提现中与互转并发策略**(例如提现处理中禁止互转或冻结可用余额)。 + +### 5.2 产品规则与运营纪律 + +- 若要坚持「活动赠送本质不可提现」的对外话术,建议在对外说明中写清:**在允许「游戏币转账户余额」时,活动所得游戏币可能通过互转进入可提现资金池**——或运营侧默认关闭「币→余额」。 +- 胜利奖励若进账户余额,建议配置**合理上限/风控**(与玩法数值策划联动),避免被刷。 + +### 5.3 技术实现要点(原则) + +- **任何资金变动**尽量走统一事务 + 记账流水,避免只改余额无记录。 +- **游戏消耗**单独埋点/单据,与互转、商城等扣款隔离。 +- **提现**建议独立订单状态机(申请、审核、打款、失败回滚),与互转、游戏结算解耦。 + +--- + +## 六、总结 + +| 维度 | 评价 | +|------|------| +| **优势** | 双池清晰;无游戏币子类型实现简单;互转与奖励去向可运营配置;奖励进余额可满足可提现诉求。 | +| **劣势/风险** | 在允许「币→余额」时,活动游戏币可能间接可提现;强依赖「游戏消耗」定义与流水审计;开关误配可能带来资金池波动。 | +| **是否完善** | 业务规则在补充后已自洽;工程上需用**参数化互转策略 + 严格事件语义 + 全链路流水**补齐可运行性。 | +| **核心建议** | 不拆子类型时,用**单向互转/限额/关闭币→余额**等运营手段守住「营销币」边界,并用流水保证可审计。 | + +--- + +## 七、与当前仓库的衔接提示(非强制) + +当前后端已有「厘」计量、账户余额与游戏币字段、资产变动流水等扩展方向时,后续若落地提现与互转,通常需要: + +- **提现订单**(状态机 + 与可用余额/冻结余额的配合) +- **互转流水**(与充值、游戏结算、人工调账区分类型) +- **游戏消耗与胜利奖励**的独立业务类型(避免与其它扣款混用) + +具体表字段可在确定「冻结余额是否独立字段」后再定稿;**本文不要求增加游戏币子类型字段**。 diff --git a/玩家需求分析.md b/玩家需求分析.md new file mode 100644 index 0000000..c1c37ef --- /dev/null +++ b/玩家需求分析.md @@ -0,0 +1,120 @@ +# 玩家需求分析(后台管理侧) + +本文档用于梳理“玩家(`app_user`)管理”在现有基础上,后续常见、必要且可扩展的管理与控制能力方向,便于产品规划与研发落地。 + +## 1. 当前已具备能力(现状基线) + +结合当前仓库实现,玩家管理已覆盖: + +- **账号管理**:编辑(含欢迎词细节)、重置密码、启用/封禁(开关)、软删除 +- **资产管理**:账户余额/游戏币展示、后台充值(含充值类型、活动补贴仅游戏币约束)、流水审计写入 +- **在线与活跃**:最后登录/最后活跃/在线状态(窗口参数化)、支持按在线状态筛选 +- **注册与邀请**:注册策略(开/关/邀请制等)、邀请码管理(列表、作废、代生成、清空未使用) +- **审计与可追溯**:关键资产变动有流水(`app_asset_transactions`),登录日志已纳入“用户中心”用于审计 + +以上是后续扩展的“底座”,新需求应尽量在此基础上延展,而不是绕开造成数据割裂。 + +## 2. 后续玩家管理常见能力清单(按模块划分) + +下面能力并非都要立刻做,建议按业务优先级逐步扩展。每一类都给出“为什么需要”和“落地形态”建议。 + +### 2.1 账号与安全(高优先级) + +这类能力决定了系统能否抵御恶意登录、撞库与账号滥用,且是日常运营/客服最常用功能。 + +- **登录保护与限制** + - 目的:减少撞库、弱口令尝试、机器人攻击。 + - 形态:连续失败次数阈值、临时锁定(可自动解锁)、账号/IP 维度限流。 +- **会话/设备管理** + - 目的:解决盗号、共享账号与风控处置需要。 + - 形态:查看当前有效会话/设备列表、强制下线(踢下线)、Token 刷新/作废。 +- **密码与凭据策略** + - 目的:降低弱口令风险、强化账号安全。 + - 形态:强制改密、密码过期策略、后台重置密码强度校验、禁止常见弱口令。 +- **账号状态细分(分域封控)** + - 目的:比“封禁/启用”更细粒度控制,减少误伤。 + - 形态:可扩展为“禁言/限制交易/限制提现/限制充值/只读”等独立开关;操作需记录原因与时效。 + +### 2.2 资产与交易(高优先级) + +你当前已经有“余额/游戏币 + 人工充值 + 流水”雏形,下一步要补“可对账、可冲正、可追责”。 + +- **完整流水查询与筛选** + - 目的:对账与追溯,快速回答“钱从哪来/去哪了”。 + - 形态:按用户/时间/资产类型/充值类型/操作人/原因关键字筛选流水;支持导出(若未来需要,注意权限与审计)。 +- **冲正/回滚的策略化** + - 目的:避免直接改余额导致审计断裂。 + - 形态:所有纠错通过“反向流水”完成(例如误加 100 → 冲正 -100),并形成关联链路(原流水ID/备注)。 +- **额度与频控(如有充值/赠送/提现)** + - 目的:控制羊毛党与异常资金流动。 + - 形态:日/周/月限额、频次限制、冷却时间、超限审批(可选)。 + +### 2.3 行为与风控(中高优先级) + +风控决定系统能否抵抗批量注册、刷邀请、刷补贴等行为。你的“邀请制/活动补贴”越强,风控越重要。 + +- **风险标签体系(可运营的风控)** + - 目的:让运营与客服能“标记、筛选、批量处置”。 + - 形态:为玩家打标签(疑似羊毛、机器人、高风险、投诉多等),支持批量添加/移除;标签可用于策略联动(例如限制补贴)。 +- **维度封控(不只封账号)** + - 目的:同 IP/同设备批量注册时,封账号效果有限。 + - 形态:IP 段、设备指纹、手机号/邮箱(若有)维度的封控与限制;封控原因、时效可追溯。 +- **异常检测与告警(运营看板/列表)** + - 目的:提前发现攻击或异常增长。 + - 形态:短时间注册激增、同 IP 多账号、失败登录激增、充值后不活跃等指标;可先做列表/筛选,后续再做图表。 + +### 2.4 运营与增长(中优先级) + +这类能力提升“可运营性”。你已经有邀请关系、注册策略、活动补贴充值类型,下一步通常是分层与可配置发放。 + +- **用户分层/等级/VIP/权益** + - 目的:差异化运营,控制补贴成本。 + - 形态:等级/会员组、权益开关(补贴资格、发放上限、折扣等)。 +- **活动补贴策略(审批/上限/频次)** + - 目的:活动补贴是高风险入口,必须可控。 + - 形态:发放上限、频次限制、审批流(可选)、发放理由必填、支持批量发放与名单导入(若未来需要)。 +- **邀请关系可视化与反作弊** + - 目的:邀请制常被自邀、团伙化利用。 + - 形态:查看上下游关系、贡献统计;识别同设备/同 IP 自邀、环形邀请等异常。 + +### 2.5 合规与审计(中优先级,但越早越省事) + +当后台操作越来越多,如果不做统一审计,后面很难排查“谁改的/为何改/改了什么”。 + +- **后台对玩家的关键操作审计** + - 范围:封禁/解封、改密、充值/冲正、软删除/恢复(如未来有)、策略变更。 + - 字段:操作者、时间、原因、前后值、关联对象(用户ID、流水ID等)。 +- **导出/查询权限分级(如未来要导出)** + - 目的:减少敏感数据泄露风险。 + - 形态:导出权限独立授权、导出行为写审计、字段脱敏(手机号/邮箱等)。 + +### 2.6 客服与工单能力(中优先级,提升处理效率) + +- **玩家详情聚合页(One-page Debug)** + - 目的:客服处理问题需要“一屏看全貌”。 + - 建议聚合:基础资料、资产概览、登录日志、活跃信息、风险标签、操作历史、邀请关系。 +- **内部备注与跟进时间线** + - 目的:多人协作时避免信息丢失。 + - 形态:仅后台可见备注、支持附件/截图(可选)、支持@与状态(可选)。 + +### 2.7 系统与效率工具(中优先级) + +- **批量操作** + - 方向:批量封禁/解封、批量发放补贴、批量冲正、批量打标签。 +- **筛选维度补齐(持续增强)** + - 方向:注册时间范围、最后活跃范围、余额区间、补贴发放次数/金额、邀请人等。 + +## 3. 推荐落地顺序(建议) + +若希望“先稳再强”,建议优先级可按以下推进: + +1. **账号安全**:失败登录限制/临时锁定、会话强制下线、操作审计(最低可用版) +2. **资产治理**:流水查询完善、冲正机制、额度/频控 +3. **风控运营**:风险标签 + 维度封控 + 异常检测列表 +4. **增长运营**:分层/权益、补贴策略(上限/审批)、邀请反作弊 +5. **客服效率**:玩家详情聚合页、备注时间线、工单集成(如需要) + +## 4. 备注:关于入口放置 + +“注册策略/邀请码”与玩家增长强相关,作为**用户域策略**放在“用户中心”更容易形成闭环;“运营配置”可作为未来策略中台的聚合入口或镜像入口,但不建议迁走主入口,避免运营人员找功能割裂。 + diff --git a/菜单与路由对照清单(2026-04-23).md b/菜单与路由对照清单(2026-04-23).md new file mode 100644 index 0000000..3e19dbf --- /dev/null +++ b/菜单与路由对照清单(2026-04-23).md @@ -0,0 +1,75 @@ +# 菜单与路由对照清单(2026-04-23) + +本文档用于对照 **数据库菜单树**(你提供的 `/menu/getMenuList` 响应)与 **`web-admin` 文件路由**(`web-admin/src/router/pages/**/page.tsx`),找出: + +- 菜单里存在但代码缺少对应路由页(高概率出现“页面不存在/请从左侧导航重新进入页面”) +- 代码里存在但菜单树未使用的路由页(通常是历史别名,可选清理,不影响功能) + +--- + +## 结论摘要 + +- **已修复(媒体库 404)**:已补齐 `common/upload` 的文件路由页,菜单 `公共能力 / 媒体库(上传下载)` 可正常进入。 +- **已收敛(去别名路由)**:登录日志、参数变更日志等路由已统一为唯一规范 path,避免“前端显示与实际访问路径不一致”。 + +--- + +## 一、菜单树里存在,但前端缺少对应路由页(建议修复) + +### 1) 公共能力 / 媒体库(上传下载) + +- **菜单节点** + - **父级**:`common` + - **path**:`upload` + - **name**:`upload` + - **component**:`features/media/MediaLibraryPage` + - **menuId**:20 + +- **问题** + - 当前仓库中 **不存在** 对应的文件路由页: + - 期望存在:`web-admin/src/router/pages/common/upload/page.tsx` + - 在本项目的文件路由约束下,目录段通常必须与菜单 `path` 对齐;缺失该路由页时,进入菜单有较高概率触发 404 提示。 + +- **建议修复** + - 新增文件:`web-admin/src/router/pages/common/upload/page.tsx` + - 内容示例(说明性,不在本文直接改代码): + - `routeMeta.menuName = 'upload'` + - `export default MediaLibraryPage` + +- **修复结果(已完成)** + - 已新增文件路由页:`web-admin/src/router/pages/common/upload/page.tsx` + - 进入菜单不再触发“页面不存在”。 + +--- + +## 二、前端路由页已存在,但菜单树未引用(别名/兼容项,可选) + +以下项属于“代码里有路由页(menuName)”,但在你提供的菜单 JSON 中未出现对应 `name` 节点。 + +### 已收敛的历史别名(已处理) + +- **登录日志** + - 规范路径:`userCenter/appLoginLog` + - 已移除历史别名:`userCenter/loginLog` + +- **参数变更日志** + - 规范路径:`opsConfig/sysParamChangeLog` + - 已移除历史别名:`opsConfig/paramChangeLog` + +--- + +## 三、你截图菜单项覆盖情况(补充对照) + +你截图中的菜单项在当前仓库均已具备对应路由页与组件: + +- **用户中心**:玩家管理 / 注册策略 / 邀请码管理 / 登录日志 / 资产流水 +- **财务中心**:资产流水 / 提现管理(占位落地页)/ 提现订单(三级) +- **运营配置**:风险标签 / 参数变更日志 / 资产策略 + +--- + +## 四、下一步建议 + +1. 建议把“数据库菜单去别名/规范 path”的修复固化为幂等初始化,避免数据漂移导致前端出现别名路由。 +2. 后续若继续收敛其它模块,请坚持“一个 `menuName` 只对应一个文件路由 path”的原则。 +