add: 仪表盘

This commit is contained in:
邹方成 2026-02-04 12:44:37 +08:00
parent 9eea272d69
commit 571cb2f4db
5 changed files with 411 additions and 54 deletions

View File

@ -1,9 +1,11 @@
package admin
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
"bindbox-game/internal/code"
@ -274,6 +276,25 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
}
}
// 批量查询下线(被邀请人)累计消费金额
// 从 user_invites.accumulated_amount 聚合,该字段在被邀请人支付订单时自动更新
inviteeTotalConsumes := make(map[int64]int64)
if len(userIDs) > 0 {
type consumeResult struct {
InviterID int64
Total int64
}
var consumes []consumeResult
h.readDB.UserInvites.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserInvites.InviterID, h.readDB.UserInvites.AccumulatedAmount.Sum().As("total")).
Where(h.readDB.UserInvites.InviterID.In(userIDs...)).
Group(h.readDB.UserInvites.InviterID).
Scan(&consumes)
for _, c := range consumes {
inviteeTotalConsumes[c.InviterID] = c.Total
}
}
// 批量查询次数卡数量
gamePassCounts := make(map[int64]int64)
if len(userIDs) > 0 {
@ -425,6 +446,7 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
ThirtyDayConsume: thirtyDayConsume[v.ID],
TotalConsume: totalConsume[v.ID],
InviteCount: inviteCounts[v.ID],
InviteeTotalConsume: inviteeTotalConsumes[v.ID],
GamePassCount: gpCount,
GameTicketCount: gtCount,
InventoryValue: invVal,
@ -445,11 +467,16 @@ type listInvitesResponse struct {
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []adminUserItem `json:"list"`
Summary struct {
TotalConsume int64 `json:"total_consume"` // 所有下线累计消费
TotalAsset int64 `json:"total_asset"` // 所有下线资产价值
TotalProfit int64 `json:"total_profit"` // 所有下线总盈亏
} `json:"summary"`
}
// ListUserInvites 查看用户邀请列表
// @Summary 查看用户邀请列表
// @Description 查看指定用户邀请的用户列表
// @Description 查看指定用户邀请的用户列表,包含每个被邀请人的累计消费金额
// @Tags 管理端.用户
// @Accept json
// @Produce json
@ -478,13 +505,96 @@ func (h *handler) ListUserInvites() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20103, err.Error()))
return
}
// 获取被邀请人ID列表批量查询消费金额
inviteeIDs := make([]int64, len(rows))
for i, v := range rows {
inviteeIDs[i] = v.ID
}
// 从 user_invites 表查询每个被邀请人的累计消费
inviteeConsumes := make(map[int64]int64)
if len(inviteeIDs) > 0 {
type consumeResult struct {
InviteeID int64
Amount int64
}
var consumes []consumeResult
h.readDB.UserInvites.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserInvites.InviteeID, h.readDB.UserInvites.AccumulatedAmount.As("amount")).
Where(h.readDB.UserInvites.InviterID.Eq(userID)).
Where(h.readDB.UserInvites.InviteeID.In(inviteeIDs...)).
Scan(&consumes)
for _, c := range consumes {
inviteeConsumes[c.InviteeID] = c.Amount
}
}
// 批量查询被邀请人的累计获得商品价值
// 真实盈亏 = 商品价值(持有+发货,排除兑换)- 累计消费
// 注:已兑换(status=2)的商品不计入,因为兑换=转成积分,积分是用户的现金等价物
inviteeAssets := make(map[int64]int64)
if len(inviteeIDs) > 0 {
type assetResult struct {
UserID int64
Value int64
}
// 商品价值:排除已兑换(status=2)
var invRes []assetResult
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
Select(h.readDB.UserInventory.UserID, h.readDB.Products.Price.Sum().As("value")).
Where(h.readDB.UserInventory.UserID.In(inviteeIDs...)).
Where(h.readDB.UserInventory.Status.Neq(2)). // 排除已兑换
Group(h.readDB.UserInventory.UserID).
Scan(&invRes)
for _, r := range invRes {
inviteeAssets[r.UserID] = r.Value
}
}
// 查询所有下线的汇总数据(不分页)
var summaryConsume, summaryAsset int64
// 累计消费汇总
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(accumulated_amount), 0)
FROM user_invites
WHERE inviter_id = ?
`, userID).Scan(&summaryConsume).Error
// 资产价值汇总(不包含已兑换的商品)
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(p.price), 0)
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id IN (SELECT invitee_id FROM user_invites WHERE inviter_id = ?)
AND ui.status != 2
`, userID).Scan(&summaryAsset).Error
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
rsp.List = make([]adminUserItem, len(rows))
for i, v := range rows {
rsp.List[i] = adminUserItem{ID: v.ID, Nickname: v.Nickname, Avatar: v.Avatar, InviteCode: v.InviteCode, InviterID: v.InviterID, CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05")}
consume := inviteeConsumes[v.ID]
asset := inviteeAssets[v.ID]
rsp.List[i] = adminUserItem{
ID: v.ID,
Nickname: v.Nickname,
Avatar: v.Avatar,
InviteCode: v.InviteCode,
InviterID: v.InviterID,
CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"),
TotalConsume: consume, // 被邀请人的累计消费
TotalAssetValue: asset, // 被邀请人的资产价值
}
}
// 填充汇总数据
rsp.Summary.TotalConsume = summaryConsume
rsp.Summary.TotalAsset = summaryAsset
rsp.Summary.TotalProfit = summaryAsset - summaryConsume
ctx.Payload(rsp)
}
}
@ -1031,6 +1141,23 @@ func (h *handler) ListUserAuditLogs() core.HandlerFunc {
if t, err := time.Parse(time.RFC3339, logs[i].CreatedAt); err == nil {
logs[i].CreatedAt = t.Format("2006-01-02 15:04:05")
}
// 处理金额显示 (分 -> 元)
if logs[i].Category == "points" || logs[i].Category == "order" {
if amount, err := strconv.ParseFloat(logs[i].AmountStr, 64); err == nil {
val := amount / 100.0
if logs[i].Category == "order" {
val = -math.Abs(val) // 订单固定为支出
}
// 积分正数加+号
if logs[i].Category == "points" && val > 0 {
logs[i].AmountStr = fmt.Sprintf("+%.2f", val)
} else {
logs[i].AmountStr = fmt.Sprintf("%.2f", val)
}
}
}
// 翻译 Shipping Status 等 (可选项,也可以前端做)
// 翻译 Shipping Status 等 (可选项,也可以前端做)
if logs[i].Category == "shipping" {
switch logs[i].SubType {
@ -1044,6 +1171,54 @@ func (h *handler) ListUserAuditLogs() core.HandlerFunc {
logs[i].SubType = "异常"
}
}
// 翻译积分变动
if logs[i].Category == "points" {
switch logs[i].SubType {
case "redeem_coupon":
logs[i].SubType = "兑换优惠券"
case "redeem_product":
logs[i].SubType = "兑换商品"
case "redeem_item_card":
logs[i].SubType = "兑换道具卡"
case "minesweeper_settle":
logs[i].SubType = "扫雷游戏奖励"
case "game_reward":
logs[i].SubType = "游戏奖励"
case "redeem_reward":
logs[i].SubType = "奖品分解"
case "pay_reward":
logs[i].SubType = "支付奖励"
case "manual", "manual_add":
logs[i].SubType = "管理员调整"
case "task_reward":
logs[i].SubType = "任务中心奖励"
case "order_reward":
logs[i].SubType = "订单奖励"
case "douyin_product_reward":
logs[i].SubType = "抖店商品奖励"
case "signin":
logs[i].SubType = "签到奖励"
case "draw_cost":
logs[i].SubType = "抽奖消耗"
}
// 翻译 RefInfo
if strings.HasPrefix(logs[i].RefInfo, "system_coupons:") {
logs[i].RefInfo = strings.Replace(logs[i].RefInfo, "system_coupons:", "系统优惠券ID: ", 1)
}
if strings.HasPrefix(logs[i].RefInfo, "user_inventory:") {
logs[i].RefInfo = strings.Replace(logs[i].RefInfo, "user_inventory:", "用户背包ID: ", 1)
}
if strings.HasPrefix(logs[i].RefInfo, "orders:") {
logs[i].RefInfo = strings.Replace(logs[i].RefInfo, "orders:", "关联订单号: ", 1)
}
}
// 翻译订单状态
if logs[i].Category == "order" && logs[i].SubType == "paid" {
logs[i].SubType = "支付成功"
}
if logs[i].Category == "draw" && logs[i].SubType == "win" {
logs[i].SubType = "中奖"
}
}
ctx.Payload(&listAuditLogsResponse{
@ -1162,6 +1337,7 @@ type adminUserItem struct {
ThirtyDayConsume int64 `json:"thirty_day_consume"` // 近30天消费
TotalConsume int64 `json:"total_consume"` // 累计消费
InviteCount int64 `json:"invite_count"` // 邀请人数
InviteeTotalConsume int64 `json:"invitee_total_consume"` // 下线(被邀请人)累计消费
GamePassCount int64 `json:"game_pass_count"` // 次数卡数量
GameTicketCount int64 `json:"game_ticket_count"` // 游戏资格数量
InventoryValue int64 `json:"inventory_value"` // 持有商品总价值
@ -1706,3 +1882,17 @@ func (h *handler) UpdateUserStatus() core.HandlerFunc {
ctx.Payload(map[string]any{"success": true})
}
}
type listAuditLogsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type auditLogItem struct {
CreatedAt string `json:"created_at"`
Category string `json:"category"` // points, order, shipping, draw
SubType string `json:"sub_type"` // 具体类型
AmountStr string `json:"amount_str"` // 变动金额/数量
RefInfo string `json:"ref_info"` // 关联信息
DetailInfo string `json:"detail_info"` // 详细信息
}

View File

@ -0,0 +1,73 @@
package app
import (
"net/http"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
usersvc "bindbox-game/internal/service/user"
"go.uber.org/zap"
)
type bindInviterRequest struct {
InviteCode string `json:"invite_code" binding:"required"`
}
type bindInviterResponse struct {
InviterID int64 `json:"inviter_id"`
InviterNickname string `json:"inviter_nickname"`
}
// BindInviter 用户绑定邀请人
// @Summary 用户绑定邀请人
// @Description 未绑定过邀请人的用户可主动填写邀请码绑定邀请人(绑定后不可更改)
// @Tags APP端.用户
// @Accept json
// @Produce json
// @Param body body bindInviterRequest true "邀请码"
// @Success 200 {object} bindInviterResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/users/inviter/bind [post]
// @Security LoginVerifyToken
func (h *handler) BindInviter() core.HandlerFunc {
return func(ctx core.Context) {
req := new(bindInviterRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID := int64(ctx.SessionUserInfo().Id)
result, err := h.user.BindInviter(ctx.RequestContext(), userID, usersvc.BindInviterInput{
InviteCode: req.InviteCode,
})
if err != nil {
switch err {
case usersvc.ErrAlreadyBound:
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10100, "您已绑定过邀请人,无法重复绑定"))
case usersvc.ErrInvalidCode:
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10101, "邀请码不存在"))
case usersvc.ErrCannotInviteSelf:
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10102, "不能邀请自己"))
default:
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10103, err.Error()))
}
return
}
// 触发任务中心的邀请成功逻辑(给邀请人发放奖励)
if result.InviterID > 0 {
if err := h.task.OnInviteSuccess(ctx.RequestContext(), result.InviterID, userID); err != nil {
h.logger.Error("触发邀请任务失败", zap.Error(err), zap.Int64("inviter_id", result.InviterID), zap.Int64("invitee_id", userID))
}
}
ctx.Payload(&bindInviterResponse{
InviterID: result.InviterID,
InviterNickname: result.InviterNickname,
})
}
}

View File

@ -464,6 +464,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
appAuthApiRouter.POST("/users/:user_id/douyin/phone/bind", userHandler.DouyinBindPhone())
appAuthApiRouter.POST("/users/douyin/bind", userHandler.BindDouyinOrder())
appAuthApiRouter.GET("/users/:user_id/invites", userHandler.ListUserInvites())
appAuthApiRouter.POST("/users/inviter/bind", userHandler.BindInviter())
appAuthApiRouter.GET("/users/:user_id/inventory", userHandler.ListUserInventory())
appAuthApiRouter.GET("/users/:user_id/shipments", userHandler.ListUserShipments())
appAuthApiRouter.GET("/users/:user_id/item_cards", userHandler.ListUserItemCards())

View File

@ -0,0 +1,91 @@
package user
import (
"context"
"errors"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// BindInviterInput 绑定邀请人输入
type BindInviterInput struct {
InviteCode string // 邀请人的邀请码
}
// BindInviterOutput 绑定邀请人输出
type BindInviterOutput struct {
InviterID int64 `json:"inviter_id"`
InviterNickname string `json:"inviter_nickname"`
}
var (
ErrAlreadyBound = errors.New("already_bound")
ErrInvalidCode = errors.New("invalid_code")
ErrCannotInviteSelf = errors.New("cannot_invite_self")
)
// BindInviter 用户主动绑定邀请人(仅限未绑定过的用户)
func (s *service) BindInviter(ctx context.Context, userID int64, in BindInviterInput) (*BindInviterOutput, error) {
var result *BindInviterOutput
err := s.writeDB.Transaction(func(tx *dao.Query) error {
// 1. 获取当前用户,加行锁
user, err := tx.Users.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(tx.Users.ID.Eq(userID)).First()
if err != nil {
return err
}
// 2. 检查是否已绑定邀请人
if user.InviterID != 0 {
return ErrAlreadyBound
}
// 3. 查找邀请人
inviter, err := tx.Users.WithContext(ctx).Where(tx.Users.InviteCode.Eq(in.InviteCode)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidCode
}
return err
}
// 4. 不能邀请自己
if inviter.ID == userID {
return ErrCannotInviteSelf
}
// 5. 创建邀请记录
invite := &model.UserInvites{
InviterID: inviter.ID,
InviteeID: userID,
InviteCode: in.InviteCode,
}
if err := tx.UserInvites.WithContext(ctx).Create(invite); err != nil {
return err
}
// 6. 更新用户的邀请人ID
if _, err := tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(userID)).
UpdateColumn(tx.Users.InviterID, inviter.ID); err != nil {
return err
}
result = &BindInviterOutput{
InviterID: inviter.ID,
InviterNickname: inviter.Nickname,
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}

View File

@ -80,6 +80,8 @@ type Service interface {
SendSmsCode(ctx context.Context, mobile string) error
LoginByCode(ctx context.Context, in SmsLoginInput) (*SmsLoginOutput, error)
GrantGamePass(ctx context.Context, userID int64, packageID int64, count int32, orderNo string) error
// 邀请人绑定
BindInviter(ctx context.Context, userID int64, in BindInviterInput) (*BindInviterOutput, error)
}
type service struct {