2173 lines
72 KiB
Go
Executable File
2173 lines
72 KiB
Go
Executable File
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()).UnderlyingDB().
|
||
Select("user_inventory.user_id, COALESCE(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), products.price, 0)), 0) AS value").
|
||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||
Where("user_inventory.user_id IN ?", userIDs).
|
||
Where("user_inventory.status = ?", 1). // 1=持有
|
||
Group("user_inventory.user_id").
|
||
Scan(&invRes).Error
|
||
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()).UnderlyingDB().
|
||
Select("user_inventory.user_id, COALESCE(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), products.price, 0)), 0) AS value").
|
||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||
Where("user_inventory.user_id IN ?", inviteeIDs).
|
||
Where("user_inventory.status != ?", 2). // 排除已兑换
|
||
Group("user_inventory.user_id").
|
||
Scan(&invRes).Error
|
||
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(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 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 {
|
||
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(NULLIF(ui.value_cents, 0), 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})
|
||
}
|
||
}
|