package admin import ( "fmt" "math" "net/http" "strconv" "strings" "time" "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/service/user" ) type listUsersRequest struct { Page int `form:"page"` PageSize int `form:"page_size"` Nickname string `form:"nickname"` InviteCode string `form:"inviteCode"` StartDate string `form:"startDate"` EndDate string `form:"endDate"` ID string `form:"id"` DouyinID string `form:"douyin_id"` DouyinUserID string `form:"douyin_user_id"` } type listUsersResponse struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` List []adminUserItem `json:"list"` } // ListAppUsers 管理端用户列表 // @Summary 管理端用户列表 // @Description 查看APP端用户分页列表 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param page query int true "页码" default(1) // @Param page_size query int true "每页数量,最多100" default(20) // @Param nickname query string false "用户昵称" // @Param inviteCode query string false "邀请码" // @Param startDate query string false "开始日期(YYYY-MM-DD)" // @Param endDate query string false "结束日期(YYYY-MM-DD)" // @Success 200 {object} listUsersResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users [get] // @Security LoginVerifyToken func (h *handler) ListAppUsers() core.HandlerFunc { return func(ctx core.Context) { req := new(listUsersRequest) rsp := new(listUsersResponse) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = 20 } if req.PageSize > 100 { req.PageSize = 100 } u := h.readDB.Users c := h.readDB.Channels q := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB(). LeftJoin(c, c.ID.EqCol(u.ChannelID)). Select( u.ALL, c.Name.As("channel_name"), c.Code.As("channel_code"), ) // 应用搜索条件 if req.ID != "" { if id, err := strconv.ParseInt(req.ID, 10, 64); err == nil { q = q.Where(h.readDB.Users.ID.Eq(id)) } } if req.Nickname != "" { q = q.Where(h.readDB.Users.Nickname.Like("%" + req.Nickname + "%")) } if req.InviteCode != "" { q = q.Where(h.readDB.Users.InviteCode.Eq(req.InviteCode)) } if req.StartDate != "" { if startTime, err := time.Parse("2006-01-02", req.StartDate); err == nil { q = q.Where(h.readDB.Users.CreatedAt.Gte(startTime)) } } if req.EndDate != "" { if endTime, err := time.Parse("2006-01-02", req.EndDate); err == nil { // 设置结束时间为当天的23:59:59 endTime = endTime.Add(24 * time.Hour).Add(-time.Second) q = q.Where(h.readDB.Users.CreatedAt.Lte(endTime)) } } if req.DouyinID != "" { q = q.Where(h.readDB.Users.DouyinID.Like("%" + req.DouyinID + "%")) } if req.DouyinUserID != "" { q = q.Where(h.readDB.Users.DouyinUserID.Like("%" + req.DouyinUserID + "%")) } total, err := q.Count() if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20101, err.Error())) return } type result struct { model.Users ChannelName string ChannelCode string } var rows []result if err := q.Order(h.readDB.Users.ID.Desc()).Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Scan(&rows); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20102, err.Error())) return } // 获取用户ID列表以批量查询资产 userIDs := make([]int64, len(rows)) for i, v := range rows { userIDs[i] = v.ID } // 批量查询优惠券数量(未使用的) couponCounts := make(map[int64]int64) if len(userIDs) > 0 { type countResult struct { UserID int64 Count int64 } var counts []countResult h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB(). Select(h.readDB.UserCoupons.UserID, h.readDB.UserCoupons.ID.Count().As("count")). Where(h.readDB.UserCoupons.UserID.In(userIDs...)). Where(h.readDB.UserCoupons.Status.Eq(1)). // 1=未使用 Group(h.readDB.UserCoupons.UserID). Scan(&counts) for _, c := range counts { couponCounts[c.UserID] = c.Count } } // 批量查询积分余额 pointBalances := make(map[int64]int64) if len(userIDs) > 0 { type balResult struct { UserID int64 Points int64 } var bRes []balResult h.readDB.UserPoints.WithContext(ctx.RequestContext()).ReadDB(). Select(h.readDB.UserPoints.UserID, h.readDB.UserPoints.Points.Sum().As("points")). Where(h.readDB.UserPoints.UserID.In(userIDs...)). Group(h.readDB.UserPoints.UserID). Scan(&bRes) for _, b := range bRes { pointBalances[b.UserID] = int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), b.Points)) } } // 批量查询优惠券数量(未使用的) cardCounts := make(map[int64]int64) if len(userIDs) > 0 { type countResult struct { UserID int64 Count int64 } var counts []countResult h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB(). Select(h.readDB.UserItemCards.UserID, h.readDB.UserItemCards.ID.Count().As("count")). Where(h.readDB.UserItemCards.UserID.In(userIDs...)). Where(h.readDB.UserItemCards.Status.Eq(1)). // 1=未使用 Group(h.readDB.UserItemCards.UserID). Scan(&counts) for _, c := range counts { cardCounts[c.UserID] = c.Count } } // 批量查询消费统计 todayConsume := make(map[int64]int64) sevenDayConsume := make(map[int64]int64) thirtyDayConsume := make(map[int64]int64) totalConsume := make(map[int64]int64) if len(userIDs) > 0 { now := time.Now() todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) sevenDayStart := todayStart.AddDate(0, 0, -6) thirtyDayStart := todayStart.AddDate(0, 0, -29) type consumeResult struct { UserID int64 Amount int64 } // 当日消费 var todayRes []consumeResult h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")). Where(h.readDB.Orders.UserID.In(userIDs...)). Where(h.readDB.Orders.Status.Eq(2)). Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) Where(h.readDB.Orders.CreatedAt.Gte(todayStart)). Group(h.readDB.Orders.UserID). Scan(&todayRes) for _, r := range todayRes { todayConsume[r.UserID] = r.Amount } // 近7天消费 var sevenRes []consumeResult h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")). Where(h.readDB.Orders.UserID.In(userIDs...)). Where(h.readDB.Orders.Status.Eq(2)). Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) Where(h.readDB.Orders.CreatedAt.Gte(sevenDayStart)). Group(h.readDB.Orders.UserID). Scan(&sevenRes) for _, r := range sevenRes { sevenDayConsume[r.UserID] = r.Amount } // 近30天消费 var thirtyRes []consumeResult h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")). Where(h.readDB.Orders.UserID.In(userIDs...)). Where(h.readDB.Orders.Status.Eq(2)). Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) Where(h.readDB.Orders.CreatedAt.Gte(thirtyDayStart)). Group(h.readDB.Orders.UserID). Scan(&thirtyRes) for _, r := range thirtyRes { thirtyDayConsume[r.UserID] = r.Amount } // 累计消费 var totalRes []consumeResult h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")). Where(h.readDB.Orders.UserID.In(userIDs...)). Where(h.readDB.Orders.Status.Eq(2)). Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) Group(h.readDB.Orders.UserID). Scan(&totalRes) for _, r := range totalRes { totalConsume[r.UserID] = r.Amount } } // 批量查询邀请人数 inviteCounts := make(map[int64]int64) if len(userIDs) > 0 { type countResult struct { InviterID int64 Count int64 } var counts []countResult h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB(). Select(h.readDB.Users.InviterID, h.readDB.Users.ID.Count().As("count")). Where(h.readDB.Users.InviterID.In(userIDs...)). Group(h.readDB.Users.InviterID). Scan(&counts) for _, c := range counts { inviteCounts[c.InviterID] = c.Count } } // 批量查询下线(被邀请人)累计消费金额 // 从 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 { type countResult struct { UserID int64 Count int64 } var counts []countResult now := time.Now() h.readDB.UserGamePasses.WithContext(ctx.RequestContext()).ReadDB(). Select(h.readDB.UserGamePasses.UserID, h.readDB.UserGamePasses.Remaining.Sum().As("count")). Where(h.readDB.UserGamePasses.UserID.In(userIDs...)). Where(h.readDB.UserGamePasses.Remaining.Gt(0)). Where(h.readDB.UserGamePasses.Where(h.readDB.UserGamePasses.ExpiredAt.Gt(now)).Or(h.readDB.UserGamePasses.ExpiredAt.IsNull())). Group(h.readDB.UserGamePasses.UserID). Scan(&counts) for _, c := range counts { gamePassCounts[c.UserID] = c.Count } } // 批量查询游戏资格数量 gameTicketCounts := make(map[int64]int64) if len(userIDs) > 0 { type countResult struct { UserID int64 Count int64 } var counts []countResult h.readDB.UserGameTickets.WithContext(ctx.RequestContext()).ReadDB(). Select(h.readDB.UserGameTickets.UserID, h.readDB.UserGameTickets.Available.Sum().As("count")). Where(h.readDB.UserGameTickets.UserID.In(userIDs...)). Group(h.readDB.UserGameTickets.UserID). Scan(&counts) for _, c := range counts { gameTicketCounts[c.UserID] = c.Count } } // 批量查询持有商品价值 inventoryValues := make(map[int64]int64) if len(userIDs) > 0 { type invResult struct { UserID int64 Value int64 } var invRes []invResult 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.UserInventory.ValueCents.Sum().As("value")). Where(h.readDB.UserInventory.UserID.In(userIDs...)). Where(h.readDB.UserInventory.Status.Eq(1)). // 1=持有 Group(h.readDB.UserInventory.UserID). Scan(&invRes) for _, r := range invRes { inventoryValues[r.UserID] = r.Value } } // 批量查询优惠券价值(余额之和) couponValues := make(map[int64]int64) if len(userIDs) > 0 { type valResult struct { UserID int64 Value int64 } var vRes []valResult h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB(). Select(h.readDB.UserCoupons.UserID, h.readDB.UserCoupons.BalanceAmount.Sum().As("value")). Where(h.readDB.UserCoupons.UserID.In(userIDs...)). Where(h.readDB.UserCoupons.Status.Eq(1)). Group(h.readDB.UserCoupons.UserID). Scan(&vRes) for _, v := range vRes { couponValues[v.UserID] = v.Value } } // 批量查询道具卡价值 itemCardValues := make(map[int64]int64) if len(userIDs) > 0 { type valResult struct { UserID int64 Value int64 } var vRes []valResult h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB(). LeftJoin(h.readDB.SystemItemCards, h.readDB.SystemItemCards.ID.EqCol(h.readDB.UserItemCards.CardID)). Select(h.readDB.UserItemCards.UserID, h.readDB.SystemItemCards.Price.Sum().As("value")). Where(h.readDB.UserItemCards.UserID.In(userIDs...)). Where(h.readDB.UserItemCards.Status.Eq(1)). Group(h.readDB.UserItemCards.UserID). Scan(&vRes) for _, v := range vRes { itemCardValues[v.UserID] = v.Value } } // 批量查询所有用户的邀请人昵称 inviterNicknames := make(map[int64]string) inviterIDs := make([]int64, 0) for _, v := range rows { if v.InviterID > 0 { inviterIDs = append(inviterIDs, v.InviterID) } } if len(inviterIDs) > 0 { inviters, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.In(inviterIDs...)).Find() for _, inv := range inviters { inviterNicknames[inv.ID] = inv.Nickname } } rsp.Page = req.Page rsp.PageSize = req.PageSize rsp.Total = total rsp.List = make([]adminUserItem, len(rows)) for i, v := range rows { pointsBal := pointBalances[v.ID] invVal := inventoryValues[v.ID] cpVal := couponValues[v.ID] icVal := itemCardValues[v.ID] gpCount := gamePassCounts[v.ID] gtCount := gameTicketCounts[v.ID] // 总资产估值逻辑:积分余额 + 商品价值 + 优惠券价值 + 道具卡价值 + 次数卡(2元/次) // 游戏资格不计入估值(购买其他商品赠送,无实际价值) 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], InviteeTotalConsume: inviteeTotalConsumes[v.ID], GamePassCount: gpCount, GameTicketCount: gtCount, InventoryValue: invVal, TotalAssetValue: assetVal, Status: v.Status, } } ctx.Payload(rsp) } } type listInvitesRequest struct { Page int `form:"page"` PageSize int `form:"page_size"` } type listInvitesResponse struct { Page int `json:"page"` 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 查看指定用户邀请的用户列表,包含每个被邀请人的累计消费金额 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param page query int true "页码" default(1) // @Param page_size query int true "每页数量,最多100" default(20) // @Success 200 {object} listInvitesResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/invites [get] // @Security LoginVerifyToken func (h *handler) ListUserInvites() core.HandlerFunc { return func(ctx core.Context) { req := new(listInvitesRequest) rsp := new(listInvitesResponse) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } rows, total, err := h.userSvc.ListInvites(ctx.RequestContext(), userID, req.Page, req.PageSize) if err != nil { 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(ui.value_cents), 0) FROM user_inventory ui 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 { 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) } } type listOrdersRequest struct { Page int `form:"page"` PageSize int `form:"page_size"` } type listOrdersResponse struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` List []*user.OrderWithItems `json:"list"` } type listInventoryRequest struct { Page int `form:"page"` PageSize int `form:"page_size"` Keyword string `form:"keyword"` // 搜索关键词(商品名称) Status int32 `form:"status"` // 状态筛选:0=全部, 1=持有, 2=作废, 3=已使用 } type listInventoryResponse struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` List []*user.InventoryWithProduct `json:"list"` } // ListUserOrders 查看用户订单列表 // @Summary 查看用户订单列表 // @Description 查看指定用户的订单记录 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param page query int true "页码" default(1) // @Param page_size query int true "每页数量,最多100" default(20) // @Success 200 {object} listOrdersResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/orders [get] // @Security LoginVerifyToken func (h *handler) ListUserOrders() core.HandlerFunc { return func(ctx core.Context) { req := new(listOrdersRequest) rsp := new(listOrdersResponse) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } items, total, err := h.userSvc.ListOrdersWithItems(ctx.RequestContext(), userID, 0, nil, req.Page, req.PageSize) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20104, err.Error())) return } rsp.Page = req.Page rsp.PageSize = req.PageSize rsp.Total = total rsp.List = items ctx.Payload(rsp) } } // 查看用户资产列表 // @Summary 查看用户资产列表 // @Description 查看指定用户的资产记录 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param page query int true "页码" default(1) // @Param page_size query int true "每页数量,最多100" default(20) // @Param keyword query string false "搜索关键词" // @Param status query int false "状态筛选: 0=全部, 1=持有, 2=作废, 3=已使用" // @Success 200 {object} listInventoryResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/inventory [get] // @Security LoginVerifyToken func (h *handler) ListUserInventory() core.HandlerFunc { return func(ctx core.Context) { req := new(listInventoryRequest) rsp := new(listInventoryResponse) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } // 处理分页参数 if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = 20 } if req.PageSize > 100 { req.PageSize = 100 } // 如果有搜索关键词,使用带搜索的查询 if req.Keyword != "" { // 联表查询以支持按商品名称搜索 ui := h.readDB.UserInventory p := h.readDB.Products // Check if keyword is numeric numKeyword, errNum := strconv.ParseInt(req.Keyword, 10, 64) // Count query logic countQ := h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB(). LeftJoin(p, p.ID.EqCol(ui.ProductID)). Where(ui.UserID.Eq(userID)) // 应用状态筛选 if req.Status > 0 { countQ = countQ.Where(ui.Status.Eq(req.Status)) } else { // 默认只过滤掉已软删除的记录(如果有的话,status=2是作废,通常后台要能看到作废的,所以这里如果不传status默认查所有非删除的?) // 既然是管理端,如果不传status,应该显示所有状态的记录 } if errNum == nil { // Keyword is numeric, search by name OR ID OR OrderID countQ = countQ.Where( ui.Where(p.Name.Like("%" + req.Keyword + "%")). Or(ui.ID.Eq(numKeyword)). Or(ui.OrderID.Eq(numKeyword)), ) } else { // Keyword is not numeric, search by name only countQ = countQ.Where(p.Name.Like("%" + req.Keyword + "%")) } total, err := countQ.Count() if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error())) return } // 查询资产数据 type inventoryRow struct { ID int64 UserID int64 ProductID int64 OrderID int64 ActivityID int64 RewardID int64 Status int32 Remark string CreatedAt string UpdatedAt string ProductName string ProductImages string ProductPrice int64 } var rows []inventoryRow sql := ` SELECT ui.id, ui.user_id, ui.product_id, ui.order_id, ui.activity_id, ui.reward_id, ui.status, ui.remark, ui.created_at, ui.updated_at, p.name as product_name, p.images_json as product_images, COALESCE(ui.value_cents, p.price, 0) as product_price FROM user_inventory ui LEFT JOIN products p ON p.id = ui.product_id WHERE ui.user_id = ? ` var args []interface{} args = append(args, userID) if req.Status > 0 { sql += " AND ui.status = ?" args = append(args, req.Status) } if errNum == nil { sql += " AND (p.name LIKE ? OR ui.id = ? OR ui.order_id = ?)" args = append(args, "%"+req.Keyword+"%", numKeyword, numKeyword) } else { sql += " AND p.name LIKE ?" args = append(args, "%"+req.Keyword+"%") } sql += " ORDER BY ui.id DESC LIMIT ? OFFSET ?" args = append(args, req.PageSize, (req.Page-1)*req.PageSize) err = h.repo.GetDbR().Raw(sql, args...).Scan(&rows).Error if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error())) return } // 转换结果 items := make([]*user.InventoryWithProduct, len(rows)) for i, r := range rows { items[i] = &user.InventoryWithProduct{ UserInventory: &model.UserInventory{ ID: r.ID, UserID: r.UserID, ProductID: r.ProductID, OrderID: r.OrderID, ActivityID: r.ActivityID, RewardID: r.RewardID, Status: r.Status, Remark: r.Remark, }, ProductName: r.ProductName, ProductImages: r.ProductImages, ProductPrice: r.ProductPrice, } } rsp.Page = req.Page rsp.PageSize = req.PageSize rsp.Total = total rsp.List = items ctx.Payload(rsp) return } // 无搜索关键词时使用原有逻辑 rows, total, err := h.userSvc.ListInventoryWithProduct(ctx.RequestContext(), userID, req.Page, req.PageSize, req.Status) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error())) return } rsp.Page = req.Page rsp.PageSize = req.PageSize rsp.Total = total rsp.List = rows ctx.Payload(rsp) } } type listUserItemCardsRequest struct { Page int `form:"page"` PageSize int `form:"page_size"` } type listUserItemCardsResponse struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` List []*user.ItemCardWithTemplate `json:"list"` } // ListUserItemCards 查看用户道具卡列表 // @Summary 查看用户道具卡列表 // @Description 查看指定用户的道具卡持有记录 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param page query int true "页码" default(1) // @Param page_size query int true "每页数量,最多100" default(20) // @Success 200 {object} listUserItemCardsResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/item_cards [get] // @Security LoginVerifyToken func (h *handler) ListUserItemCards() core.HandlerFunc { return func(ctx core.Context) { req := new(listUserItemCardsRequest) rsp := new(listUserItemCardsResponse) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } items, total, err := h.userSvc.ListUserItemCardsWithTemplate(ctx.RequestContext(), userID, req.Page, req.PageSize) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20106, err.Error())) return } rsp.Page = req.Page rsp.PageSize = req.PageSize rsp.Total = total rsp.List = items ctx.Payload(rsp) } } type listCouponsRequest struct { Page int `form:"page"` PageSize int `form:"page_size"` } type adminUserCouponItem struct { ID int64 `json:"id"` CouponID int64 `json:"coupon_id"` Status int32 `json:"status"` UsedOrderID int64 `json:"used_order_id"` UsedAt string `json:"used_at"` ValidStart string `json:"valid_start"` ValidEnd string `json:"valid_end"` Name string `json:"name"` ScopeType int32 `json:"scope_type"` DiscountType int32 `json:"discount_type"` DiscountValue int64 `json:"discount_value"` MinSpend int64 `json:"min_spend"` BalanceAmount int64 `json:"balance_amount"` } type listCouponsResponse struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` List []adminUserCouponItem `json:"list"` } // ListUserCoupons 查看用户优惠券列表 // @Summary 查看用户优惠券列表 // @Description 查看指定用户持有的优惠券列表 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param page query int true "页码" default(1) // @Param page_size query int true "每页数量,最多100" default(20) // @Success 200 {object} listCouponsResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/coupons [get] // @Security LoginVerifyToken func (h *handler) ListUserCoupons() core.HandlerFunc { return func(ctx core.Context) { req := new(listCouponsRequest) rsp := new(listCouponsResponse) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } // 统计总数 base := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserCoupons.UserID.Eq(userID)) total, err := base.Count() if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error())) return } // 联表查询 system_coupons 获取优惠券模板信息 type row struct { ID int64 CouponID int64 Status int32 UsedOrderID int64 UsedAt *string ValidStart *string ValidEnd *string Name string ScopeType int32 DiscountType int32 DiscountValue int64 MinSpend int64 BalanceAmount int64 } q := base. Select( h.readDB.UserCoupons.ID, h.readDB.UserCoupons.CouponID, h.readDB.UserCoupons.Status, h.readDB.UserCoupons.UsedOrderID, h.readDB.UserCoupons.UsedAt, h.readDB.UserCoupons.ValidStart, h.readDB.UserCoupons.ValidEnd, h.readDB.SystemCoupons.Name, h.readDB.SystemCoupons.ScopeType, h.readDB.SystemCoupons.DiscountType, h.readDB.SystemCoupons.DiscountValue, h.readDB.SystemCoupons.MinSpend, ). LeftJoin(h.readDB.SystemCoupons, h.readDB.SystemCoupons.ID.EqCol(h.readDB.UserCoupons.CouponID)). Order(h.readDB.UserCoupons.ID.Desc()). Limit(req.PageSize).Offset((req.Page - 1) * req.PageSize) var rows []row if err := q.Scan(&rows); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error())) return } rsp.Page = req.Page rsp.PageSize = req.PageSize rsp.Total = total rsp.List = make([]adminUserCouponItem, len(rows)) for i, v := range rows { rsp.List[i] = adminUserCouponItem{ ID: v.ID, CouponID: v.CouponID, Status: v.Status, UsedOrderID: v.UsedOrderID, UsedAt: nullableToString(v.UsedAt), ValidStart: nullableToString(v.ValidStart), ValidEnd: nullableToString(v.ValidEnd), Name: v.Name, ScopeType: v.ScopeType, DiscountType: v.DiscountType, DiscountValue: v.DiscountValue, MinSpend: v.MinSpend, BalanceAmount: v.BalanceAmount, } } ctx.Payload(rsp) } } type AuditLogItem struct { CreatedAt string `json:"created_at"` // 时间 Category string `json:"category"` // 大类: points/order/shipping/draw SubType string `json:"sub_type"` // 子类: action/status AmountStr string `json:"amount_str"` // 金额/数值变化 (带符号字符串) RefInfo string `json:"ref_info"` // 关联信息 (RefID/OrderNo/ExpressNo) DetailInfo string `json:"detail_info"` // 详细描述 (Remark/PrizeName) } type listAuditLogsResponse struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` // 由于UNION ALL分页较难精确Count Total,这里可能返回估算值或分步Count,为简化MVP先只做翻页不用Total或者Total设为0 List []AuditLogItem `json:"list"` } // ListUserAuditLogs 查看用户行为审计日志 // @Summary 查看用户行为审计日志 // @Description 聚合查看用户的积分、订单、发货、抽奖等行为记录 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param page query int true "页码" default(1) // @Param page_size query int true "每页数量" default(20) // @Success 200 {object} listAuditLogsResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/audit [get] // @Security LoginVerifyToken func (h *handler) ListUserAuditLogs() core.HandlerFunc { return func(ctx core.Context) { req := new(listInvitesRequest) // 复用分页参数结构 if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = 20 } offset := (req.Page - 1) * req.PageSize limit := req.PageSize var logs []AuditLogItem // 构建 UNION ALL 查询 // 1. 积分流水 // 2. 订单记录 // 3. 发货记录 // 4. 抽奖记录 (只看中奖的? 或者全部? 这里先只看中奖 IsWinner=1 避免数据量太大) sql := ` SELECT * FROM ( -- 1. Points Ledger SELECT created_at, 'points' as category, CONVERT(action USING utf8mb4) as sub_type, CAST(points AS CHAR) as amount_str, CONCAT(CONVERT(ref_table USING utf8mb4), ':', CONVERT(ref_id USING utf8mb4)) as ref_info, CONVERT(remark USING utf8mb4) as detail_info FROM user_points_ledger WHERE user_id = ? UNION ALL -- 2. Orders SELECT created_at, 'order' as category, 'paid' as sub_type, CAST(actual_amount AS CHAR) as amount_str, CONVERT(order_no USING utf8mb4) as ref_info, CONVERT(remark USING utf8mb4) as detail_info FROM orders WHERE user_id = ? AND status = 2 UNION ALL -- 3. Shipping Records SELECT created_at, 'shipping' as category, CAST(status AS CHAR) as sub_type, CAST(quantity AS CHAR) as amount_str, CONCAT(IFNULL(CONVERT(express_code USING utf8mb4),''), ':', IFNULL(CONVERT(express_no USING utf8mb4),'')) as ref_info, CONVERT(remark USING utf8mb4) as detail_info FROM shipping_records WHERE user_id = ? UNION ALL -- 4. Draw Logs (Winners) SELECT l.created_at, 'draw' as category, IF(l.is_winner=1, 'win', 'lose') as sub_type, CAST(1 AS CHAR) as amount_str, CAST(l.order_id AS CHAR) as ref_info, CONCAT( '游戏: ', IFNULL(CONVERT(act.name USING utf8mb4), '未知'), ' | 奖品: ', IFNULL(CONVERT(prod.name USING utf8mb4), '未知'), ' | 级别: ', CASE l.level WHEN 1 THEN 'S' WHEN 2 THEN 'A' WHEN 3 THEN 'B' WHEN 4 THEN 'C' ELSE CAST(l.level AS CHAR) END ) as detail_info FROM activity_draw_logs l LEFT JOIN activity_issues issue ON l.issue_id = issue.id LEFT JOIN activities act ON issue.activity_id = act.id LEFT JOIN activity_reward_settings reward ON l.reward_id = reward.id LEFT JOIN products prod ON reward.product_id = prod.id WHERE l.user_id = ? AND l.is_winner = 1 ) as combined_logs ORDER BY created_at DESC LIMIT ? OFFSET ? ` if err := h.repo.GetDbR().Raw(sql, userID, userID, userID, userID, limit, offset).Scan(&logs).Error; err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20107, err.Error())) return } // 格式化处理 (Optional) for i := range logs { // 将时间标准化 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) } } } // [Fix] 针对直播间订单(金额为0),尝试从备注中提取关联的抖店订单号并查询实际支付金额 // 备注格式示例: "直播间抽奖: xxx (关联抖店订单: 69505...)" // 收集需要查询的 ShopOrderID shopOrderIDs := make([]string, 0) logIndicesMap := make(map[string][]int) // shopOrderID -> []logIndex for i := range logs { // 仅处理 order 类型且金额为 0 的记录 (通常直播间订单 actual_amount=0) // 或者 category=order 且 sub_type=paid if logs[i].Category == "order" && logs[i].SubType == "paid" { // 简单判断金额是否为 0.00 (前面已经格式化过) if logs[i].AmountStr == "-0.00" || logs[i].AmountStr == "0.00" { // 尝试提取关联抖店订单 if strings.Contains(logs[i].DetailInfo, "关联抖店订单:") { // 提取 ID: last part after "关联抖店订单:" and trim ")" parts := strings.Split(logs[i].DetailInfo, "关联抖店订单:") if len(parts) > 1 { orderIDPart := strings.TrimSpace(parts[1]) orderIDPart = strings.TrimRight(orderIDPart, ")") if orderIDPart != "" { shopOrderIDs = append(shopOrderIDs, orderIDPart) logIndicesMap[orderIDPart] = append(logIndicesMap[orderIDPart], i) } } } } } } // 批量查询 douyin_orders if len(shopOrderIDs) > 0 { var dyOrders []struct { ShopOrderID string `gorm:"column:shop_order_id"` ActualPayAmount int64 `gorm:"column:actual_pay_amount"` } // 只需要查 shop_order_id 和 actual_pay_amount // 使用 raw sql 或者 model find // 这里为了简单直接用 h.readDB.DouyinOrders (Gen生成的) 或者 Raw SQL // 假设 h.readDB.DouyinOrders 可用,或者直接用 h.repo.GetDbR() if err := h.repo.GetDbR().Table("douyin_orders").Select("shop_order_id, actual_pay_amount"). Where("shop_order_id IN ?", shopOrderIDs).Scan(&dyOrders).Error; err == nil { // 更新日志金额 for _, dy := range dyOrders { if indices, ok := logIndicesMap[dy.ShopOrderID]; ok { for _, idx := range indices { // 转换为浮点数 (元) val := float64(dy.ActualPayAmount) / 100.0 // 订单固定为支出 (负数) val = -math.Abs(val) logs[idx].AmountStr = fmt.Sprintf("%.2f", val) } } } } } // 翻译 Shipping Status 等 (可选项,也可以前端做) // 翻译 Shipping Status 等 (可选项,也可以前端做) if logs[i].Category == "shipping" { switch logs[i].SubType { case "1": logs[i].SubType = "待发货" case "2": logs[i].SubType = "已发货" case "3": logs[i].SubType = "已签收" case "4": 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{ Page: req.Page, PageSize: req.PageSize, Total: 0, // 为了性能暂时忽略 List: logs, }) } } func nullableToString(s *string) string { if s == nil { return "" } return *s } type listPointsRequest struct { Page int `form:"page"` PageSize int `form:"page_size"` } type adminUserPointsLedgerItem struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` Action string `json:"action"` Points float64 `json:"points"` // 改为 float64 支持小数积分 RefTable string `json:"ref_table"` RefID string `json:"ref_id"` Remark string `json:"remark"` CreatedAt string `json:"created_at"` } type listPointsResponse struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` List []adminUserPointsLedgerItem `json:"list"` } // ListUserPoints 查看用户积分记录 // @Summary 查看用户积分记录 // @Description 查看指定用户的积分流水记录 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param page query int true "页码" default(1) // @Param page_size query int true "每页数量,最多100" default(20) // @Success 200 {object} listPointsResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/points [get] // @Security LoginVerifyToken func (h *handler) ListUserPoints() core.HandlerFunc { return func(ctx core.Context) { req := new(listPointsRequest) rsp := new(listPointsResponse) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } items, total, err := h.userSvc.ListPointsLedger(ctx.RequestContext(), userID, req.Page, req.PageSize) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20106, err.Error())) return } rsp.Page = req.Page rsp.PageSize = req.PageSize rsp.Total = total // Convert ledger items rsp.List = make([]adminUserPointsLedgerItem, len(items)) for i, v := range items { rsp.List[i] = adminUserPointsLedgerItem{ ID: v.ID, UserID: v.UserID, Action: v.Action, Points: h.userSvc.CentsToPointsFloat(ctx.RequestContext(), v.Points), RefTable: v.RefTable, RefID: v.RefID, Remark: v.Remark, CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"), } } ctx.Payload(rsp) } } type pointsBalanceResponse struct { Balance int64 `json:"balance"` } 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"` // 邀请人数 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 查看用户积分余额 // @Summary 查看用户积分余额 // @Description 查看指定用户当前积分余额(过滤过期) // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Success 200 {object} pointsBalanceResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/points/balance [get] // @Security LoginVerifyToken func (h *handler) GetUserPointsBalance() core.HandlerFunc { return func(ctx core.Context) { rsp := new(pointsBalanceResponse) userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } total, err := h.userSvc.GetPointsBalance(ctx.RequestContext(), userID) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20107, err.Error())) return } rsp.Balance = int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), total)) ctx.Payload(rsp) } } type addPointsRequest struct { Points float64 `json:"points"` // 正数=增加,负数=扣减 Kind string `json:"kind"` Remark string `json:"remark"` ValidDays *int `json:"valid_days"` } type addPointsResponse struct { Success bool `json:"success"` } // AddUserPoints 给用户增加或扣减积分 // @Summary 给用户增加或扣减积分 // @Description 管理端为指定用户发放或扣减积分,正数为增加,负数为扣减 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param RequestBody body addPointsRequest true "请求参数" // @Success 200 {object} addPointsResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/points/add [post] // @Security LoginVerifyToken func (h *handler) AddUserPoints() core.HandlerFunc { return func(ctx core.Context) { req := new(addPointsRequest) rsp := new(addPointsResponse) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } if req.Points == 0 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "积分变动值不能为0")) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } // 将浮点数积分转换为分(Cents) // 1 积分 = 1 元 = 100 分 // 使用 math.Round 避免精度问题 pointsCents := int64(math.Round(req.Points * 100)) // 如果是扣减积分,先检查余额 if pointsCents < 0 { balance, _ := h.userSvc.GetPointsBalance(ctx.RequestContext(), userID) deductCents := -pointsCents if balance < deductCents { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20108, "积分余额不足,无法扣减")) return } } var validStart *time.Time var validEnd *time.Time now := time.Now() // 只有增加积分时才设置有效期 if pointsCents > 0 { validStart = &now if req.ValidDays != nil && *req.ValidDays > 0 { ve := now.Add(time.Duration(*req.ValidDays) * 24 * time.Hour) validEnd = &ve } } if err := h.userSvc.AddPoints(ctx.RequestContext(), userID, pointsCents, req.Kind, req.Remark, validStart, validEnd); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20108, err.Error())) return } rsp.Success = true ctx.Payload(rsp) } } type addCouponRequest struct { CouponID int64 `json:"coupon_id" binding:"required"` } type addCouponResponse struct { Success bool `json:"success"` } // AddUserCoupon 给用户添加优惠券 // @Summary 给用户添加优惠券 // @Description 管理端为指定用户发放优惠券 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param RequestBody body addCouponRequest true "请求参数" // @Success 200 {object} addCouponResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/coupons/add [post] // @Security LoginVerifyToken func (h *handler) AddUserCoupon() core.HandlerFunc { return func(ctx core.Context) { req := new(addCouponRequest) rsp := new(addCouponResponse) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } if ctx.SessionUserInfo().IsSuper != 1 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) return } if err := h.userSvc.AddCoupon(ctx.RequestContext(), userID, req.CouponID); err != nil { msg := err.Error() if msg == "unsupported data" { msg = "发券失败:模板配额已满" } ctx.AbortWithError(core.Error(http.StatusBadRequest, 20109, msg)) return } rsp.Success = true ctx.Payload(rsp) } } type voidUserCouponRequest struct { } func (h *handler) VoidUserCoupon() core.HandlerFunc { return func(ctx core.Context) { req := new(voidUserCouponRequest) if err := ctx.ShouldBindJSON(req); err != nil && err.Error() != "EOF" { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } ucid, err := strconv.ParseInt(ctx.Param("user_coupon_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递持券ID")) return } if ctx.SessionUserInfo().IsSuper != 1 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) return } adminID := int64(ctx.SessionUserInfo().Id) if err := h.userSvc.VoidUserCoupon(ctx.RequestContext(), adminID, userID, ucid); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20109, err.Error())) return } ctx.Payload(simpleMessageResponse{Message: "操作成功"}) } } type voidUserItemCardRequest struct { } func (h *handler) VoidUserItemCard() core.HandlerFunc { return func(ctx core.Context) { req := new(voidUserItemCardRequest) if err := ctx.ShouldBindJSON(req); err != nil && err.Error() != "EOF" { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } icid, err := strconv.ParseInt(ctx.Param("user_item_card_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递道具卡持有ID")) return } if ctx.SessionUserInfo().IsSuper != 1 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) return } adminID := int64(ctx.SessionUserInfo().Id) if err := h.userSvc.VoidUserItemCard(ctx.RequestContext(), adminID, userID, icid); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20109, err.Error())) return } ctx.Payload(simpleMessageResponse{Message: "操作成功"}) } } type voidUserInventoryRequest struct { } func (h *handler) VoidUserInventory() core.HandlerFunc { return func(ctx core.Context) { req := new(voidUserInventoryRequest) if err := ctx.ShouldBindJSON(req); err != nil && err.Error() != "EOF" { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } invID, err := strconv.ParseInt(ctx.Param("inventory_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递资产ID")) return } if ctx.SessionUserInfo().IsSuper != 1 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) return } adminID := int64(ctx.SessionUserInfo().Id) if err := h.userSvc.VoidUserInventory(ctx.RequestContext(), adminID, userID, invID); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20109, err.Error())) return } ctx.Payload(simpleMessageResponse{Message: "操作成功"}) } } type adminUserTitleItem struct { ID int64 `json:"id"` TitleID int64 `json:"title_id"` Name string `json:"name"` Description string `json:"description"` ObtainedAt string `json:"obtained_at"` ExpiresAt string `json:"expires_at"` Status int32 `json:"status"` } type listUserTitlesResponse struct { List []adminUserTitleItem `json:"list"` } func (h *handler) ListUserTitles() core.HandlerFunc { return func(ctx core.Context) { rsp := new(listUserTitlesResponse) userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } type row struct { ID int64 TitleID int64 Active int32 ObtainedAt *string ExpiresAt *string Name string Description string } q := h.readDB.UserTitles.WithContext(ctx.RequestContext()).ReadDB(). LeftJoin(h.readDB.SystemTitles, h.readDB.SystemTitles.ID.EqCol(h.readDB.UserTitles.TitleID)). Select( h.readDB.UserTitles.ID, h.readDB.UserTitles.TitleID, h.readDB.UserTitles.Active, h.readDB.UserTitles.ObtainedAt, h.readDB.UserTitles.ExpiresAt, h.readDB.SystemTitles.Name, h.readDB.SystemTitles.Description, ). Where(h.readDB.UserTitles.UserID.Eq(userID)). Order(h.readDB.UserTitles.ID.Desc()) var rows []row if err := q.Scan(&rows); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20110, err.Error())) return } rsp.List = make([]adminUserTitleItem, len(rows)) for i, v := range rows { rsp.List[i] = adminUserTitleItem{ ID: v.ID, TitleID: v.TitleID, Name: v.Name, Description: v.Description, ObtainedAt: nullableToString(v.ObtainedAt), ExpiresAt: nullableToString(v.ExpiresAt), Status: v.Active, } } ctx.Payload(rsp) } } type listUserCouponUsageRequest struct { Page int `form:"page"` PageSize int `form:"page_size"` } type adminUserCouponUsageItem struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` UserCouponID int64 `json:"user_coupon_id"` ChangeAmount int64 `json:"change_amount"` BalanceAfter int64 `json:"balance_after"` OrderID int64 `json:"order_id"` Action string `json:"action"` CreatedAt string `json:"created_at"` } type listUserCouponUsageResponse struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` List []adminUserCouponUsageItem `json:"list"` } func (h *handler) ListUserCouponUsage() core.HandlerFunc { return func(ctx core.Context) { req := new(listUserCouponUsageRequest) rsp := new(listUserCouponUsageResponse) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = 20 } if req.PageSize > 100 { req.PageSize = 100 } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } ucid, err := strconv.ParseInt(ctx.Param("user_coupon_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递持券ID")) return } var total int64 db := h.repo.GetDbR().Model(&model.UserCouponLedger{}).Where("user_id = ? AND user_coupon_id = ?", userID, ucid) _ = db.Count(&total).Error var list []model.UserCouponLedger _ = db.Order("id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&list).Error rows := make([]adminUserCouponUsageItem, len(list)) for i, v := range list { rows[i] = adminUserCouponUsageItem{ ID: v.ID, UserID: v.UserID, UserCouponID: v.UserCouponID, ChangeAmount: v.ChangeAmount, BalanceAfter: v.BalanceAfter, OrderID: v.OrderID, Action: v.Action, CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"), } } rsp.Page = req.Page rsp.PageSize = req.PageSize rsp.Total = total if rows == nil { rows = []adminUserCouponUsageItem{} } rsp.List = rows ctx.Payload(rsp) } } // LinkUserDouyinRequest 关联用户抖音账号请求 type LinkUserDouyinRequest struct { DouyinUserID string `json:"douyin_user_id" binding:"required"` } // UpdateUserDouyinID 更新用户的抖音账号ID // @Summary 更新用户抖音ID // @Description 管理员绑定或修改用户的抖音账号ID // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param body body LinkUserDouyinRequest true "抖音用户ID" // @Success 200 {object} map[string]any // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/douyin_user_id [put] // @Security LoginVerifyToken func (h *handler) UpdateUserDouyinID() core.HandlerFunc { return func(ctx core.Context) { userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效")) return } req := new(LinkUserDouyinRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } // 更新用户抖音ID _, err = h.writeDB.Users.WithContext(ctx.RequestContext()). Where(h.writeDB.Users.ID.Eq(userID)). Update(h.writeDB.Users.DouyinUserID, req.DouyinUserID) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20301, "更新失败: "+err.Error())) return } ctx.Payload(map[string]any{ "success": true, "message": "抖音ID更新成功", }) } } // updateUserRemarkRequest 更新用户备注请求 type updateUserRemarkRequest struct { Remark string `json:"remark"` } // UpdateUserRemark 更新用户备注 // @Summary 更新用户备注 // @Description 管理员修改用户备注 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param body body updateUserRemarkRequest true "备注信息" // @Success 200 {object} map[string]any // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/remark [put] // @Security LoginVerifyToken func (h *handler) UpdateUserRemark() core.HandlerFunc { return func(ctx core.Context) { userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效")) return } req := new(updateUserRemarkRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } // 更新用户备注 _, err = h.writeDB.Users.WithContext(ctx.RequestContext()). Where(h.writeDB.Users.ID.Eq(userID)). Update(h.writeDB.Users.Remark, req.Remark) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20302, "更新失败: "+err.Error())) return } ctx.Payload(map[string]any{ "success": true, "message": "备注更新成功", }) } } type updateUserStatusRequest struct { Status int32 `json:"status" form:"status"` // 1=正常 2=禁用 3=黑名单 } // UpdateUserStatus 修改用户状态 // @Summary 修改用户状态 // @Description 管理员修改用户状态(1正常 2禁用 3黑名单) // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param body body updateUserStatusRequest true "状态信息" // @Success 200 {object} map[string]any // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/status [put] // @Security LoginVerifyToken func (h *handler) UpdateUserStatus() core.HandlerFunc { return func(ctx core.Context) { req := new(updateUserStatusRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) return } if req.Status != 1 && req.Status != 2 && req.Status != 3 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的状态值")) return } // 使用 Updates 以支持更新为 0 (虽然这里status不为0) 但 gorm Update 单列更安全 _, err = h.writeDB.Users.WithContext(ctx.RequestContext()). Where(h.writeDB.Users.ID.Eq(userID)). Update(h.writeDB.Users.Status, req.Status) if err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) return } ctx.Payload(map[string]any{"success": true}) } } // updateUserMobileRequest 更新用户手机号请求 type updateUserMobileRequest struct { Mobile string `json:"mobile" binding:"required"` } // UpdateUserMobile 更新用户手机号 // @Summary 更新用户手机号 // @Description 管理员修改用户手机号 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Param body body updateUserMobileRequest true "手机号信息" // @Success 200 {object} map[string]any // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/mobile [put] // @Security LoginVerifyToken func (h *handler) UpdateUserMobile() core.HandlerFunc { return func(ctx core.Context) { userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效")) return } req := new(updateUserMobileRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } // 验证手机号格式(中国大陆手机号:11位数字,1开头) if len(req.Mobile) != 11 || req.Mobile[0] != '1' { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "手机号格式不正确")) return } // 检查手机号是否已被其他用户使用 existingUser, err := h.readDB.Users.WithContext(ctx.RequestContext()). Where(h.readDB.Users.Mobile.Eq(req.Mobile)). Where(h.readDB.Users.ID.Neq(userID)). First() if err == nil && existingUser != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "该手机号已被其他用户使用")) return } // 更新用户手机号 _, err = h.writeDB.Users.WithContext(ctx.RequestContext()). Where(h.writeDB.Users.ID.Eq(userID)). Update(h.writeDB.Users.Mobile, req.Mobile) if err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "更新失败: "+err.Error())) return } ctx.Payload(map[string]any{ "success": true, "message": "手机号更新成功", }) } } 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"` // 详细信息 } // DeleteUser 删除用户 // @Summary 删除用户 // @Description 管理员删除用户及其所有关联数据(订单、积分、优惠券、道具卡、背包等) // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "用户ID" // @Success 200 {object} map[string]any // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id} [delete] // @Security LoginVerifyToken func (h *handler) DeleteUser() core.HandlerFunc { return func(ctx core.Context) { userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效")) return } // 检查用户是否存在 user, err := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(userID)).First() if err != nil { ctx.AbortWithError(core.Error(http.StatusNotFound, code.ParamBindError, "用户不存在")) return } // 调用 service 层的删除方法 if err := h.userSvc.DeleteUser(ctx.RequestContext(), userID); err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.DeleteUserError, fmt.Sprintf("删除用户失败: %s", err.Error()))) return } ctx.Payload(map[string]any{ "success": true, "message": fmt.Sprintf("用户 %s (ID:%d) 已成功删除", user.Nickname, userID), }) } } // adminBindInviterRequest 管理端绑定/修改邀请人请求 type adminBindInviterRequest struct { InviterUserID int64 `json:"inviter_user_id"` // 0 = 解绑 } // adminSearchUserRequest 搜索用户请求(用于邀请人选择) type adminSearchUserRequest struct { Keyword string `form:"keyword"` // ID 或手机号 } // AdminBindInviter 管理端修改用户邀请人 // @Summary 管理端修改用户邀请人 // @Description 运营可强制绑定/修改/解绑用户的邀请人,inviter_user_id=0 时为解绑 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param user_id path integer true "被操作用户ID" // @Param body body adminBindInviterRequest true "新邀请人ID" // @Success 200 {object} user.AdminBindInviterOutput // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/inviter [put] // @Security LoginVerifyToken func (h *handler) AdminBindInviter() core.HandlerFunc { return func(ctx core.Context) { userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效")) return } req := new(adminBindInviterRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } // 从会话获取操作人ID operatorID := int64(ctx.SessionUserInfo().Id) result, err := h.userSvc.AdminBindInviter(ctx.RequestContext(), user.AdminBindInviterInput{ TargetUserID: userID, InviterUserID: req.InviterUserID, OperatorID: operatorID, }) if err != nil { msg := err.Error() switch msg { case "target_user_not_found": msg = "目标用户不存在" case "inviter_user_not_found": msg = "邀请人用户不存在" case "cannot_invite_self": msg = "不能将自己设为邀请人" } ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, msg)) return } ctx.Payload(result) } } // AdminSearchUsers 管理端搜索用户(供邀请人选择框使用) // @Summary 搜索用户 // @Description 按 ID 或手机号模糊搜索,用于邀请人选择 // @Tags 管理端.用户 // @Accept json // @Produce json // @Param keyword query string true "用户ID或手机号" // @Success 200 {object} map[string]any // @Router /api/admin/users/search [get] // @Security LoginVerifyToken func (h *handler) AdminSearchUsers() core.HandlerFunc { return func(ctx core.Context) { req := new(adminSearchUserRequest) if err := ctx.ShouldBindForm(req); err != nil || req.Keyword == "" { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "keyword 不能为空")) return } type userItem struct { ID int64 `json:"id"` Nickname string `json:"nickname"` Mobile string `json:"mobile"` Avatar string `json:"avatar"` } q := h.readDB.Users.WithContext(ctx.RequestContext()) // 尝试按 ID 精确匹配 if id, err := strconv.ParseInt(req.Keyword, 10, 64); err == nil { rows, _ := q.Where(h.readDB.Users.ID.Eq(id)).Limit(10).Find() items := make([]userItem, 0, len(rows)) for _, r := range rows { items = append(items, userItem{ID: r.ID, Nickname: r.Nickname, Mobile: r.Mobile, Avatar: r.Avatar}) } ctx.Payload(map[string]any{"list": items}) return } // 按手机号或昵称模糊匹配 rows, _ := q.Where( h.readDB.Users.Mobile.Like("%" + req.Keyword + "%"), ).Or( h.readDB.Users.Nickname.Like("%" + req.Keyword + "%"), ).Limit(10).Find() items := make([]userItem, 0, len(rows)) for _, r := range rows { items = append(items, userItem{ID: r.ID, Nickname: r.Nickname, Mobile: r.Mobile, Avatar: r.Avatar}) } ctx.Payload(map[string]any{"list": items}) } }