From 571cb2f4db5d572a550aec4a4499289aa27681a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=96=B9=E6=88=90?= Date: Wed, 4 Feb 2026 12:44:37 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E4=BB=AA=E8=A1=A8=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/admin/users_admin.go | 298 +++++++++++++++++++++----- internal/api/user/bind_inviter_app.go | 73 +++++++ internal/router/router.go | 1 + internal/service/user/bind_inviter.go | 91 ++++++++ internal/service/user/user.go | 2 + 5 files changed, 411 insertions(+), 54 deletions(-) create mode 100644 internal/api/user/bind_inviter_app.go create mode 100644 internal/service/user/bind_inviter.go diff --git a/internal/api/admin/users_admin.go b/internal/api/admin/users_admin.go index f1ed6ac..da32657 100644 --- a/internal/api/admin/users_admin.go +++ b/internal/api/admin/users_admin.go @@ -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 { @@ -404,32 +425,33 @@ func (h *handler) ListAppUsers() core.HandlerFunc { assetVal := pointsBal*100 + invVal + cpVal + icVal + gpCount*200 rsp.List[i] = adminUserItem{ - ID: v.ID, - Nickname: v.Nickname, - Avatar: v.Avatar, - InviteCode: v.InviteCode, - InviterID: v.InviterID, - InviterNickname: inviterNicknames[v.InviterID], - CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"), - DouyinID: v.DouyinID, - DouyinUserID: v.DouyinUserID, - Mobile: v.Mobile, - Remark: v.Remark, - ChannelName: v.ChannelName, - ChannelCode: v.ChannelCode, - PointsBalance: pointsBal, - CouponsCount: couponCounts[v.ID], - ItemCardsCount: cardCounts[v.ID], - TodayConsume: todayConsume[v.ID], - SevenDayConsume: sevenDayConsume[v.ID], - ThirtyDayConsume: thirtyDayConsume[v.ID], - TotalConsume: totalConsume[v.ID], - InviteCount: inviteCounts[v.ID], - GamePassCount: gpCount, - GameTicketCount: gtCount, - InventoryValue: invVal, - TotalAssetValue: assetVal, - Status: v.Status, + ID: v.ID, + Nickname: v.Nickname, + Avatar: v.Avatar, + InviteCode: v.InviteCode, + InviterID: v.InviterID, + InviterNickname: inviterNicknames[v.InviterID], + CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"), + DouyinID: v.DouyinID, + DouyinUserID: v.DouyinUserID, + Mobile: v.Mobile, + Remark: v.Remark, + ChannelName: v.ChannelName, + ChannelCode: v.ChannelCode, + PointsBalance: pointsBal, + CouponsCount: couponCounts[v.ID], + ItemCardsCount: cardCounts[v.ID], + TodayConsume: todayConsume[v.ID], + SevenDayConsume: sevenDayConsume[v.ID], + ThirtyDayConsume: thirtyDayConsume[v.ID], + TotalConsume: totalConsume[v.ID], + InviteCount: inviteCounts[v.ID], + InviteeTotalConsume: inviteeTotalConsumes[v.ID], + GamePassCount: gpCount, + GameTicketCount: gtCount, + InventoryValue: invVal, + TotalAssetValue: assetVal, + Status: v.Status, } } ctx.Payload(rsp) @@ -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{ @@ -1141,32 +1316,33 @@ type pointsBalanceResponse struct { } type adminUserItem struct { - ID int64 `json:"id"` - Nickname string `json:"nickname"` - Avatar string `json:"avatar"` - InviteCode string `json:"invite_code"` - InviterID int64 `json:"inviter_id"` - InviterNickname string `json:"inviter_nickname"` // 邀请人昵称 - CreatedAt string `json:"created_at"` - DouyinID string `json:"douyin_id"` - DouyinUserID string `json:"douyin_user_id"` // 用户的抖音账号ID - Mobile string `json:"mobile"` // 手机号 - Remark string `json:"remark"` // 备注 - ChannelName string `json:"channel_name"` - ChannelCode string `json:"channel_code"` - PointsBalance int64 `json:"points_balance"` - CouponsCount int64 `json:"coupons_count"` - ItemCardsCount int64 `json:"item_cards_count"` - TodayConsume int64 `json:"today_consume"` - SevenDayConsume int64 `json:"seven_day_consume"` - ThirtyDayConsume int64 `json:"thirty_day_consume"` // 近30天消费 - TotalConsume int64 `json:"total_consume"` // 累计消费 - InviteCount int64 `json:"invite_count"` // 邀请人数 - GamePassCount int64 `json:"game_pass_count"` // 次数卡数量 - GameTicketCount int64 `json:"game_ticket_count"` // 游戏资格数量 - InventoryValue int64 `json:"inventory_value"` // 持有商品总价值 - TotalAssetValue int64 `json:"total_asset_value"` // 总资产估值 - Status int32 `json:"status"` // 用户状态:1正常 2禁用 3黑名单 + ID int64 `json:"id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + InviteCode string `json:"invite_code"` + InviterID int64 `json:"inviter_id"` + InviterNickname string `json:"inviter_nickname"` // 邀请人昵称 + CreatedAt string `json:"created_at"` + DouyinID string `json:"douyin_id"` + DouyinUserID string `json:"douyin_user_id"` // 用户的抖音账号ID + Mobile string `json:"mobile"` // 手机号 + Remark string `json:"remark"` // 备注 + ChannelName string `json:"channel_name"` + ChannelCode string `json:"channel_code"` + PointsBalance int64 `json:"points_balance"` + CouponsCount int64 `json:"coupons_count"` + ItemCardsCount int64 `json:"item_cards_count"` + TodayConsume int64 `json:"today_consume"` + SevenDayConsume int64 `json:"seven_day_consume"` + 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"` // 持有商品总价值 + TotalAssetValue int64 `json:"total_asset_value"` // 总资产估值 + Status int32 `json:"status"` // 用户状态:1正常 2禁用 3黑名单 } // ListAppUsers 管理端用户列表GetUserPointsBalance 查看用户积分余额 @@ -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"` // 详细信息 +} diff --git a/internal/api/user/bind_inviter_app.go b/internal/api/user/bind_inviter_app.go new file mode 100644 index 0000000..9430549 --- /dev/null +++ b/internal/api/user/bind_inviter_app.go @@ -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, + }) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 5202cfc..0c0fc57 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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()) diff --git a/internal/service/user/bind_inviter.go b/internal/service/user/bind_inviter.go new file mode 100644 index 0000000..75c2df6 --- /dev/null +++ b/internal/service/user/bind_inviter.go @@ -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 +} diff --git a/internal/service/user/user.go b/internal/service/user/user.go index e2d5c98..70eb0c6 100644 --- a/internal/service/user/user.go +++ b/internal/service/user/user.go @@ -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 {